SettingsPage.tsx 150 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234
  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, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import { useNavigate, useSearchParams } from 'react-router-dom';
  5. import { api } from '../api/client';
  6. import { useAuth } from '../contexts/AuthContext';
  7. import { formatDateOnly } from '../utils/date';
  8. import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus } from '../api/client';
  9. import { Card, CardContent, CardHeader } from '../components/Card';
  10. import { Button } from '../components/Button';
  11. import { SmartPlugCard } from '../components/SmartPlugCard';
  12. import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
  13. import { NotificationProviderCard } from '../components/NotificationProviderCard';
  14. import { AddNotificationModal } from '../components/AddNotificationModal';
  15. import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
  16. import { NotificationLogViewer } from '../components/NotificationLogViewer';
  17. import { ConfirmModal } from '../components/ConfirmModal';
  18. import { SpoolmanSettings } from '../components/SpoolmanSettings';
  19. import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
  20. import { VirtualPrinterSettings } from '../components/VirtualPrinterSettings';
  21. import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
  22. import { APIBrowser } from '../components/APIBrowser';
  23. import { virtualPrinterApi } from '../api/client';
  24. import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
  25. import { availableLanguages } from '../i18n';
  26. import { useToast } from '../contexts/ToastContext';
  27. import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
  28. import { useState, useEffect, useRef, useCallback } from 'react';
  29. import { Palette } from 'lucide-react';
  30. const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
  31. type TabType = typeof validTabs[number];
  32. export function SettingsPage() {
  33. const queryClient = useQueryClient();
  34. const navigate = useNavigate();
  35. const [searchParams, setSearchParams] = useSearchParams();
  36. const { t, i18n } = useTranslation();
  37. const { showToast } = useToast();
  38. const { authEnabled, user, refreshAuth } = useAuth();
  39. const {
  40. mode,
  41. darkStyle, darkBackground, darkAccent,
  42. lightStyle, lightBackground, lightAccent,
  43. setDarkStyle, setDarkBackground, setDarkAccent,
  44. setLightStyle, setLightBackground, setLightAccent,
  45. } = useTheme();
  46. const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
  47. const [showPlugModal, setShowPlugModal] = useState(false);
  48. const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
  49. const [showNotificationModal, setShowNotificationModal] = useState(false);
  50. const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
  51. const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
  52. const [showLogViewer, setShowLogViewer] = useState(false);
  53. const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
  54. // Initialize tab from URL params
  55. const tabParam = searchParams.get('tab');
  56. const initialTab = tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general';
  57. const [activeTab, setActiveTab] = useState<TabType>(initialTab);
  58. // Update URL when tab changes
  59. const handleTabChange = (tab: TabType) => {
  60. setActiveTab(tab);
  61. if (tab === 'general') {
  62. searchParams.delete('tab');
  63. } else {
  64. searchParams.set('tab', tab);
  65. }
  66. setSearchParams(searchParams, { replace: true });
  67. };
  68. const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
  69. const [newAPIKeyName, setNewAPIKeyName] = useState('');
  70. const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
  71. can_queue: true,
  72. can_control_printer: false,
  73. can_read_status: true,
  74. });
  75. const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
  76. const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
  77. const [testApiKey, setTestApiKey] = useState('');
  78. // Confirm modal states
  79. const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
  80. const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
  81. const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
  82. const [showReleaseNotes, setShowReleaseNotes] = useState(false);
  83. const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
  84. // Home Assistant test connection state
  85. const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
  86. const [haTestLoading, setHaTestLoading] = useState(false);
  87. // External camera test state
  88. const [extCameraTestResults, setExtCameraTestResults] = useState<Record<number, { success: boolean; error?: string; resolution?: string } | null>>({});
  89. const [extCameraTestLoading, setExtCameraTestLoading] = useState<Record<number, boolean>>({});
  90. const handleDefaultViewChange = (path: string) => {
  91. setDefaultViewState(path);
  92. setDefaultView(path);
  93. };
  94. const handleResetSidebarOrder = () => {
  95. localStorage.removeItem('sidebarOrder');
  96. window.location.reload();
  97. };
  98. const { data: settings, isLoading } = useQuery({
  99. queryKey: ['settings'],
  100. queryFn: api.getSettings,
  101. });
  102. const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
  103. queryKey: ['smart-plugs'],
  104. queryFn: api.getSmartPlugs,
  105. });
  106. // Fetch energy data for all smart plugs when on the plugs tab
  107. const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({
  108. queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],
  109. queryFn: async () => {
  110. if (!smartPlugs || smartPlugs.length === 0) return null;
  111. const statuses = await Promise.all(
  112. smartPlugs.filter(p => p.enabled).map(async (plug) => {
  113. try {
  114. const status = await api.getSmartPlugStatus(plug.id);
  115. return { plug, status };
  116. } catch {
  117. return { plug, status: null as SmartPlugStatus | null };
  118. }
  119. })
  120. );
  121. // Aggregate energy data
  122. let totalPower = 0;
  123. let totalToday = 0;
  124. let totalYesterday = 0;
  125. let totalLifetime = 0;
  126. let reachableCount = 0;
  127. for (const { status } of statuses) {
  128. if (status?.reachable && status.energy) {
  129. reachableCount++;
  130. if (status.energy.power != null) totalPower += status.energy.power;
  131. if (status.energy.today != null) totalToday += status.energy.today;
  132. if (status.energy.yesterday != null) totalYesterday += status.energy.yesterday;
  133. if (status.energy.total != null) totalLifetime += status.energy.total;
  134. }
  135. }
  136. return {
  137. totalPower,
  138. totalToday,
  139. totalYesterday,
  140. totalLifetime,
  141. reachableCount,
  142. totalPlugs: smartPlugs.filter(p => p.enabled).length,
  143. };
  144. },
  145. enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,
  146. refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab
  147. });
  148. const { data: notificationProviders, isLoading: providersLoading } = useQuery({
  149. queryKey: ['notification-providers'],
  150. queryFn: api.getNotificationProviders,
  151. });
  152. const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
  153. queryKey: ['api-keys'],
  154. queryFn: api.getAPIKeys,
  155. });
  156. const createAPIKeyMutation = useMutation({
  157. mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean }) =>
  158. api.createAPIKey(data),
  159. onSuccess: (data) => {
  160. setCreatedAPIKey(data.key || null);
  161. setShowCreateAPIKey(false);
  162. setNewAPIKeyName('');
  163. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  164. showToast('API key created');
  165. },
  166. onError: (error: Error) => {
  167. showToast(`Failed to create API key: ${error.message}`, 'error');
  168. },
  169. });
  170. const deleteAPIKeyMutation = useMutation({
  171. mutationFn: (id: number) => api.deleteAPIKey(id),
  172. onSuccess: () => {
  173. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  174. showToast('API key deleted');
  175. },
  176. onError: (error: Error) => {
  177. showToast(`Failed to delete API key: ${error.message}`, 'error');
  178. },
  179. });
  180. const { data: printers } = useQuery({
  181. queryKey: ['printers'],
  182. queryFn: api.getPrinters,
  183. });
  184. const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
  185. queryKey: ['notification-templates'],
  186. queryFn: api.getNotificationTemplates,
  187. });
  188. // Virtual printer status for tab indicator
  189. const { data: virtualPrinterSettings } = useQuery({
  190. queryKey: ['virtual-printer-settings'],
  191. queryFn: virtualPrinterApi.getSettings,
  192. refetchInterval: 10000,
  193. });
  194. const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;
  195. const { data: ffmpegStatus } = useQuery({
  196. queryKey: ['ffmpeg-status'],
  197. queryFn: api.checkFfmpeg,
  198. });
  199. const { data: versionInfo } = useQuery({
  200. queryKey: ['version'],
  201. queryFn: api.getVersion,
  202. });
  203. const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
  204. queryKey: ['updateCheck'],
  205. queryFn: api.checkForUpdates,
  206. staleTime: 5 * 60 * 1000,
  207. });
  208. const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
  209. queryKey: ['updateStatus'],
  210. queryFn: api.getUpdateStatus,
  211. refetchInterval: (query) => {
  212. const status = query.state.data as UpdateStatus | undefined;
  213. // Poll while update is in progress
  214. if (status?.status === 'downloading' || status?.status === 'installing') {
  215. return 1000;
  216. }
  217. return false;
  218. },
  219. });
  220. // MQTT status for Network tab
  221. const { data: mqttStatus } = useQuery({
  222. queryKey: ['mqtt-status'],
  223. queryFn: api.getMQTTStatus,
  224. refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab
  225. });
  226. // GitHub backup status for Backup tab indicator
  227. const { data: githubBackupStatus } = useQuery<GitHubBackupStatus>({
  228. queryKey: ['github-backup-status'],
  229. queryFn: api.getGitHubBackupStatus,
  230. });
  231. // Cloud auth status for Backup tab indicator
  232. const { data: cloudAuthStatus } = useQuery<CloudAuthStatus>({
  233. queryKey: ['cloud-status'],
  234. queryFn: api.getCloudStatus,
  235. });
  236. const applyUpdateMutation = useMutation({
  237. mutationFn: api.applyUpdate,
  238. onSuccess: (data) => {
  239. if (data.is_docker) {
  240. showToast(data.message, 'error');
  241. } else {
  242. refetchUpdateStatus();
  243. }
  244. },
  245. });
  246. // Test all notification providers
  247. const [testAllResult, setTestAllResult] = useState<{
  248. tested: number;
  249. success: number;
  250. failed: number;
  251. results: Array<{
  252. provider_id: number;
  253. provider_name: string;
  254. provider_type: string;
  255. success: boolean;
  256. message: string;
  257. }>;
  258. } | null>(null);
  259. const testAllMutation = useMutation({
  260. mutationFn: api.testAllNotificationProviders,
  261. onSuccess: (data) => {
  262. setTestAllResult(data);
  263. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  264. if (data.failed === 0) {
  265. showToast(`All ${data.tested} providers tested successfully!`, 'success');
  266. } else {
  267. showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');
  268. }
  269. },
  270. onError: (error: Error) => {
  271. showToast(`Failed to test providers: ${error.message}`, 'error');
  272. },
  273. });
  274. // Bulk action for smart plugs
  275. const bulkPlugActionMutation = useMutation({
  276. mutationFn: async (action: 'on' | 'off') => {
  277. if (!smartPlugs) return { success: 0, failed: 0 };
  278. const enabledPlugs = smartPlugs.filter(p => p.enabled);
  279. const results = await Promise.all(
  280. enabledPlugs.map(async (plug) => {
  281. try {
  282. await api.controlSmartPlug(plug.id, action);
  283. return { success: true };
  284. } catch {
  285. return { success: false };
  286. }
  287. })
  288. );
  289. return {
  290. success: results.filter(r => r.success).length,
  291. failed: results.filter(r => !r.success).length,
  292. };
  293. },
  294. onSuccess: (data, action) => {
  295. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  296. queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });
  297. if (data.failed === 0) {
  298. showToast(`All ${data.success} plugs turned ${action}`, 'success');
  299. } else {
  300. showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');
  301. }
  302. },
  303. onError: (error: Error) => {
  304. showToast(`Failed: ${error.message}`, 'error');
  305. },
  306. });
  307. // Ref for debounce timeout
  308. const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  309. const isSavingRef = useRef(false);
  310. const isInitialLoadRef = useRef(true);
  311. // Sync local state when settings load
  312. useEffect(() => {
  313. if (settings && !localSettings) {
  314. // Auto-detect external_url from browser if not set
  315. const settingsWithExternalUrl = {
  316. ...settings,
  317. external_url: settings.external_url || window.location.origin,
  318. };
  319. setLocalSettings(settingsWithExternalUrl);
  320. // Mark initial load complete after a short delay
  321. setTimeout(() => {
  322. isInitialLoadRef.current = false;
  323. }, 100);
  324. }
  325. }, [settings, localSettings]);
  326. const updateMutation = useMutation({
  327. mutationFn: api.updateSettings,
  328. onSuccess: (data) => {
  329. queryClient.setQueryData(['settings'], data);
  330. // Sync localSettings with the saved data to prevent re-triggering saves
  331. setLocalSettings(data);
  332. // Invalidate archive stats to reflect energy tracking mode change
  333. queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
  334. showToast('Settings saved', 'success');
  335. },
  336. onError: (error: Error) => {
  337. showToast(`Failed to save: ${error.message}`, 'error');
  338. },
  339. onSettled: () => {
  340. // Reset saving flag when mutation completes (success or error)
  341. isSavingRef.current = false;
  342. },
  343. });
  344. const updatePrinterMutation = useMutation({
  345. mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean }> }) =>
  346. api.updatePrinter(id, data),
  347. onSuccess: () => {
  348. queryClient.invalidateQueries({ queryKey: ['printers'] });
  349. showToast('Camera settings saved', 'success');
  350. },
  351. onError: (error: Error) => {
  352. showToast(`Failed to update printer: ${error.message}`, 'error');
  353. },
  354. });
  355. // Debounced auto-save when localSettings change
  356. useEffect(() => {
  357. // Skip if initial load or no settings
  358. if (isInitialLoadRef.current || !localSettings || !settings) {
  359. return;
  360. }
  361. // Check if there are actual changes
  362. const hasChanges =
  363. settings.auto_archive !== localSettings.auto_archive ||
  364. settings.save_thumbnails !== localSettings.save_thumbnails ||
  365. settings.capture_finish_photo !== localSettings.capture_finish_photo ||
  366. settings.default_filament_cost !== localSettings.default_filament_cost ||
  367. settings.currency !== localSettings.currency ||
  368. settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
  369. settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
  370. settings.check_updates !== localSettings.check_updates ||
  371. settings.notification_language !== localSettings.notification_language ||
  372. settings.ams_humidity_good !== localSettings.ams_humidity_good ||
  373. settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
  374. settings.ams_temp_good !== localSettings.ams_temp_good ||
  375. settings.ams_temp_fair !== localSettings.ams_temp_fair ||
  376. settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
  377. settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
  378. settings.date_format !== localSettings.date_format ||
  379. settings.time_format !== localSettings.time_format ||
  380. settings.default_printer_id !== localSettings.default_printer_id ||
  381. settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
  382. settings.ftp_retry_count !== localSettings.ftp_retry_count ||
  383. settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
  384. settings.ftp_timeout !== localSettings.ftp_timeout ||
  385. settings.mqtt_enabled !== localSettings.mqtt_enabled ||
  386. settings.mqtt_broker !== localSettings.mqtt_broker ||
  387. settings.mqtt_port !== localSettings.mqtt_port ||
  388. settings.mqtt_username !== localSettings.mqtt_username ||
  389. settings.mqtt_password !== localSettings.mqtt_password ||
  390. settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
  391. settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
  392. settings.external_url !== localSettings.external_url ||
  393. settings.ha_enabled !== localSettings.ha_enabled ||
  394. settings.ha_url !== localSettings.ha_url ||
  395. settings.ha_token !== localSettings.ha_token ||
  396. (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
  397. Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
  398. (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
  399. settings.prometheus_enabled !== localSettings.prometheus_enabled ||
  400. settings.prometheus_token !== localSettings.prometheus_token;
  401. if (!hasChanges) {
  402. return;
  403. }
  404. // Don't queue more saves while one is in progress
  405. if (isSavingRef.current) {
  406. return;
  407. }
  408. // Clear existing timeout
  409. if (saveTimeoutRef.current) {
  410. clearTimeout(saveTimeoutRef.current);
  411. }
  412. // Set new debounced save (500ms delay)
  413. saveTimeoutRef.current = setTimeout(() => {
  414. // Skip if a save is already in progress
  415. if (isSavingRef.current) {
  416. return;
  417. }
  418. isSavingRef.current = true;
  419. // Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately)
  420. const settingsToSave: AppSettingsUpdate = {
  421. auto_archive: localSettings.auto_archive,
  422. save_thumbnails: localSettings.save_thumbnails,
  423. capture_finish_photo: localSettings.capture_finish_photo,
  424. default_filament_cost: localSettings.default_filament_cost,
  425. currency: localSettings.currency,
  426. energy_cost_per_kwh: localSettings.energy_cost_per_kwh,
  427. energy_tracking_mode: localSettings.energy_tracking_mode,
  428. check_updates: localSettings.check_updates,
  429. notification_language: localSettings.notification_language,
  430. ams_humidity_good: localSettings.ams_humidity_good,
  431. ams_humidity_fair: localSettings.ams_humidity_fair,
  432. ams_temp_good: localSettings.ams_temp_good,
  433. ams_temp_fair: localSettings.ams_temp_fair,
  434. ams_history_retention_days: localSettings.ams_history_retention_days,
  435. per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
  436. date_format: localSettings.date_format,
  437. time_format: localSettings.time_format,
  438. default_printer_id: localSettings.default_printer_id,
  439. ftp_retry_enabled: localSettings.ftp_retry_enabled,
  440. ftp_retry_count: localSettings.ftp_retry_count,
  441. ftp_retry_delay: localSettings.ftp_retry_delay,
  442. ftp_timeout: localSettings.ftp_timeout,
  443. mqtt_enabled: localSettings.mqtt_enabled,
  444. mqtt_broker: localSettings.mqtt_broker,
  445. mqtt_port: localSettings.mqtt_port,
  446. mqtt_username: localSettings.mqtt_username,
  447. mqtt_password: localSettings.mqtt_password,
  448. mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
  449. mqtt_use_tls: localSettings.mqtt_use_tls,
  450. external_url: localSettings.external_url,
  451. ha_enabled: localSettings.ha_enabled,
  452. ha_url: localSettings.ha_url,
  453. ha_token: localSettings.ha_token,
  454. library_archive_mode: localSettings.library_archive_mode,
  455. library_disk_warning_gb: localSettings.library_disk_warning_gb,
  456. camera_view_mode: localSettings.camera_view_mode,
  457. prometheus_enabled: localSettings.prometheus_enabled,
  458. prometheus_token: localSettings.prometheus_token,
  459. };
  460. updateMutation.mutate(settingsToSave);
  461. }, 500);
  462. // Cleanup on unmount or when localSettings changes again
  463. return () => {
  464. if (saveTimeoutRef.current) {
  465. clearTimeout(saveTimeoutRef.current);
  466. }
  467. };
  468. }, [localSettings, settings, updateMutation]);
  469. const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
  470. setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);
  471. }, []);
  472. const handleTestExternalCamera = async (printerId: number, url: string, cameraType: string) => {
  473. if (!url) {
  474. showToast('Please enter a camera URL', 'error');
  475. return;
  476. }
  477. setExtCameraTestLoading(prev => ({ ...prev, [printerId]: true }));
  478. setExtCameraTestResults(prev => ({ ...prev, [printerId]: null }));
  479. try {
  480. const result = await api.testExternalCamera(printerId, url, cameraType);
  481. setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));
  482. if (result.success) {
  483. showToast(`Camera connected${result.resolution ? ` (${result.resolution})` : ''}`, 'success');
  484. } else {
  485. showToast(result.error || 'Connection failed', 'error');
  486. }
  487. } catch (error) {
  488. const message = error instanceof Error ? error.message : 'Test failed';
  489. setExtCameraTestResults(prev => ({ ...prev, [printerId]: { success: false, error: message } }));
  490. showToast(message, 'error');
  491. } finally {
  492. setExtCameraTestLoading(prev => ({ ...prev, [printerId]: false }));
  493. }
  494. };
  495. // Local state for camera URL inputs (to avoid saving on every keystroke)
  496. const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
  497. const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
  498. // Initialize local camera URLs from printer data
  499. useEffect(() => {
  500. if (printers) {
  501. const urls: Record<number, string> = {};
  502. printers.forEach(p => {
  503. if (p.external_camera_url && localCameraUrls[p.id] === undefined) {
  504. urls[p.id] = p.external_camera_url;
  505. }
  506. });
  507. if (Object.keys(urls).length > 0) {
  508. setLocalCameraUrls(prev => ({ ...prev, ...urls }));
  509. }
  510. }
  511. }, [printers]);
  512. const handleCameraUrlChange = (printerId: number, url: string) => {
  513. // Update local state immediately for responsive UI
  514. setLocalCameraUrls(prev => ({ ...prev, [printerId]: url }));
  515. // Clear existing timeout for this printer
  516. if (cameraUrlSaveTimeoutRef.current[printerId]) {
  517. clearTimeout(cameraUrlSaveTimeoutRef.current[printerId]);
  518. }
  519. // Debounce the save (800ms delay)
  520. cameraUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {
  521. updatePrinterMutation.mutate({
  522. id: printerId,
  523. data: { external_camera_url: url || null }
  524. });
  525. }, 800);
  526. };
  527. const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean }) => {
  528. const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean }> = {};
  529. if (updates.type !== undefined) data.external_camera_type = updates.type || null;
  530. if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
  531. updatePrinterMutation.mutate({ id: printerId, data });
  532. };
  533. if (isLoading || !localSettings) {
  534. return (
  535. <div className="p-4 md:p-8 flex justify-center">
  536. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  537. </div>
  538. );
  539. }
  540. return (
  541. <div className="p-4 md:p-8">
  542. <div className="mb-8">
  543. <h1 className="text-2xl font-bold text-white">Settings</h1>
  544. <p className="text-bambu-gray">Configure Bambuddy</p>
  545. </div>
  546. {/* Tab Navigation */}
  547. <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary overflow-x-auto">
  548. <button
  549. onClick={() => handleTabChange('general')}
  550. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
  551. activeTab === 'general'
  552. ? 'text-bambu-green border-bambu-green'
  553. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  554. }`}
  555. >
  556. General
  557. </button>
  558. <button
  559. onClick={() => handleTabChange('plugs')}
  560. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  561. activeTab === 'plugs'
  562. ? 'text-bambu-green border-bambu-green'
  563. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  564. }`}
  565. >
  566. <Plug className="w-4 h-4" />
  567. Smart Plugs
  568. {smartPlugs && smartPlugs.length > 0 && (
  569. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  570. {smartPlugs.length}
  571. </span>
  572. )}
  573. </button>
  574. <button
  575. onClick={() => handleTabChange('notifications')}
  576. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  577. activeTab === 'notifications'
  578. ? 'text-bambu-green border-bambu-green'
  579. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  580. }`}
  581. >
  582. <Bell className="w-4 h-4" />
  583. Notifications
  584. {notificationProviders && notificationProviders.length > 0 && (
  585. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  586. {notificationProviders.length}
  587. </span>
  588. )}
  589. </button>
  590. <button
  591. onClick={() => handleTabChange('filament')}
  592. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  593. activeTab === 'filament'
  594. ? 'text-bambu-green border-bambu-green'
  595. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  596. }`}
  597. >
  598. <Cylinder className="w-4 h-4" />
  599. Filament
  600. </button>
  601. <button
  602. onClick={() => handleTabChange('network')}
  603. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  604. activeTab === 'network'
  605. ? 'text-bambu-green border-bambu-green'
  606. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  607. }`}
  608. >
  609. <Wifi className="w-4 h-4" />
  610. Network
  611. <span className={`w-2 h-2 rounded-full ${mqttStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
  612. </button>
  613. <button
  614. onClick={() => handleTabChange('apikeys')}
  615. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  616. activeTab === 'apikeys'
  617. ? 'text-bambu-green border-bambu-green'
  618. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  619. }`}
  620. >
  621. <Key className="w-4 h-4" />
  622. API Keys
  623. {apiKeys && apiKeys.length > 0 && (
  624. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
  625. {apiKeys.length}
  626. </span>
  627. )}
  628. </button>
  629. <button
  630. onClick={() => handleTabChange('virtual-printer')}
  631. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  632. activeTab === 'virtual-printer'
  633. ? 'text-bambu-green border-bambu-green'
  634. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  635. }`}
  636. >
  637. <Printer className="w-4 h-4" />
  638. Virtual Printer
  639. <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
  640. </button>
  641. <button
  642. onClick={() => handleTabChange('users')}
  643. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  644. activeTab === 'users'
  645. ? 'text-bambu-green border-bambu-green'
  646. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  647. }`}
  648. >
  649. <Users className="w-4 h-4" />
  650. Users
  651. {authEnabled && (
  652. <span className={`w-2 h-2 rounded-full ${authEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
  653. )}
  654. </button>
  655. <button
  656. onClick={() => handleTabChange('backup')}
  657. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  658. activeTab === 'backup'
  659. ? 'text-bambu-green border-bambu-green'
  660. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  661. }`}
  662. >
  663. <Database className="w-4 h-4" />
  664. Backup
  665. <span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
  666. </button>
  667. </div>
  668. {/* General Tab */}
  669. {activeTab === 'general' && (
  670. <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
  671. {/* Left Column - General Settings */}
  672. <div className="space-y-6 flex-1 lg:max-w-xl">
  673. <Card>
  674. <CardHeader>
  675. <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
  676. </CardHeader>
  677. <CardContent className="space-y-4">
  678. <div>
  679. <label className="block text-sm text-bambu-gray mb-1">
  680. <Globe className="w-4 h-4 inline mr-1" />
  681. {t('settings.language')}
  682. </label>
  683. <select
  684. value={i18n.language}
  685. onChange={(e) => i18n.changeLanguage(e.target.value)}
  686. 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"
  687. >
  688. {availableLanguages.map((lang) => (
  689. <option key={lang.code} value={lang.code}>
  690. {lang.nativeName} ({lang.name})
  691. </option>
  692. ))}
  693. </select>
  694. <p className="text-xs text-bambu-gray mt-1">
  695. {t('settings.languageDescription')}
  696. </p>
  697. </div>
  698. <div>
  699. <label className="block text-sm text-bambu-gray mb-1">
  700. {t('settings.defaultView')}
  701. </label>
  702. <select
  703. value={defaultView}
  704. onChange={(e) => handleDefaultViewChange(e.target.value)}
  705. 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"
  706. >
  707. {defaultNavItems.map((item) => (
  708. <option key={item.id} value={item.to}>
  709. {t(item.labelKey)}
  710. </option>
  711. ))}
  712. </select>
  713. <p className="text-xs text-bambu-gray mt-1">
  714. {t('settings.defaultViewDescription')}
  715. </p>
  716. </div>
  717. <div className="grid grid-cols-2 gap-3">
  718. <div>
  719. <label className="block text-sm text-bambu-gray mb-1">
  720. Date Format
  721. </label>
  722. <select
  723. value={localSettings.date_format || 'system'}
  724. onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
  725. 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"
  726. >
  727. <option value="system">System Default</option>
  728. <option value="us">US (MM/DD/YYYY)</option>
  729. <option value="eu">EU (DD/MM/YYYY)</option>
  730. <option value="iso">ISO (YYYY-MM-DD)</option>
  731. </select>
  732. </div>
  733. <div>
  734. <label className="block text-sm text-bambu-gray mb-1">
  735. Time Format
  736. </label>
  737. <select
  738. value={localSettings.time_format || 'system'}
  739. onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
  740. 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"
  741. >
  742. <option value="system">System Default</option>
  743. <option value="12h">12-hour (3:30 PM)</option>
  744. <option value="24h">24-hour (15:30)</option>
  745. </select>
  746. </div>
  747. </div>
  748. <div>
  749. <label className="block text-sm text-bambu-gray mb-1">
  750. Default Printer
  751. </label>
  752. <select
  753. value={localSettings.default_printer_id ?? ''}
  754. onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
  755. 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"
  756. >
  757. <option value="">No default (ask each time)</option>
  758. {printers?.map((printer) => (
  759. <option key={printer.id} value={printer.id}>
  760. {printer.name}
  761. </option>
  762. ))}
  763. </select>
  764. <p className="text-xs text-bambu-gray mt-1">
  765. Pre-select this printer for uploads, reprints, and other operations.
  766. </p>
  767. </div>
  768. <div className="flex items-center justify-between">
  769. <div>
  770. <p className="text-white">Sidebar order</p>
  771. <p className="text-sm text-bambu-gray">
  772. Drag items in the sidebar to reorder. Reset to default order here.
  773. </p>
  774. </div>
  775. <Button
  776. variant="secondary"
  777. size="sm"
  778. onClick={handleResetSidebarOrder}
  779. >
  780. <RotateCcw className="w-4 h-4" />
  781. Reset
  782. </Button>
  783. </div>
  784. </CardContent>
  785. </Card>
  786. <Card>
  787. <CardHeader>
  788. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  789. <Palette className="w-5 h-5" />
  790. Appearance
  791. </h2>
  792. </CardHeader>
  793. <CardContent className="space-y-6">
  794. {/* Dark Mode Settings */}
  795. <div className={`space-y-3 p-4 rounded-lg border ${mode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
  796. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  797. Dark Mode
  798. {mode === 'dark' && <span className="text-xs text-bambu-green">(active)</span>}
  799. </h3>
  800. <div className="grid grid-cols-3 gap-3">
  801. <div>
  802. <label className="block text-xs text-bambu-gray mb-1">Background</label>
  803. <select
  804. value={darkBackground}
  805. onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast('Settings saved', 'success'); }}
  806. 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"
  807. >
  808. <option value="neutral">Neutral</option>
  809. <option value="warm">Warm</option>
  810. <option value="cool">Cool</option>
  811. <option value="oled">OLED Black</option>
  812. <option value="slate">Slate Blue</option>
  813. <option value="forest">Forest Green</option>
  814. </select>
  815. </div>
  816. <div>
  817. <label className="block text-xs text-bambu-gray mb-1">Accent</label>
  818. <select
  819. value={darkAccent}
  820. onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
  821. 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"
  822. >
  823. <option value="green">Green</option>
  824. <option value="teal">Teal</option>
  825. <option value="blue">Blue</option>
  826. <option value="orange">Orange</option>
  827. <option value="purple">Purple</option>
  828. <option value="red">Red</option>
  829. </select>
  830. </div>
  831. <div>
  832. <label className="block text-xs text-bambu-gray mb-1">Style</label>
  833. <select
  834. value={darkStyle}
  835. onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
  836. 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"
  837. >
  838. <option value="classic">Classic</option>
  839. <option value="glow">Glow</option>
  840. <option value="vibrant">Vibrant</option>
  841. </select>
  842. </div>
  843. </div>
  844. </div>
  845. {/* Light Mode Settings */}
  846. <div className={`space-y-3 p-4 rounded-lg border ${mode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
  847. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  848. Light Mode
  849. {mode === 'light' && <span className="text-xs text-bambu-green">(active)</span>}
  850. </h3>
  851. <div className="grid grid-cols-3 gap-3">
  852. <div>
  853. <label className="block text-xs text-bambu-gray mb-1">Background</label>
  854. <select
  855. value={lightBackground}
  856. onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast('Settings saved', 'success'); }}
  857. 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"
  858. >
  859. <option value="neutral">Neutral</option>
  860. <option value="warm">Warm</option>
  861. <option value="cool">Cool</option>
  862. </select>
  863. </div>
  864. <div>
  865. <label className="block text-xs text-bambu-gray mb-1">Accent</label>
  866. <select
  867. value={lightAccent}
  868. onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast('Settings saved', 'success'); }}
  869. 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"
  870. >
  871. <option value="green">Green</option>
  872. <option value="teal">Teal</option>
  873. <option value="blue">Blue</option>
  874. <option value="orange">Orange</option>
  875. <option value="purple">Purple</option>
  876. <option value="red">Red</option>
  877. </select>
  878. </div>
  879. <div>
  880. <label className="block text-xs text-bambu-gray mb-1">Style</label>
  881. <select
  882. value={lightStyle}
  883. onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast('Settings saved', 'success'); }}
  884. 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"
  885. >
  886. <option value="classic">Classic</option>
  887. <option value="glow">Glow</option>
  888. <option value="vibrant">Vibrant</option>
  889. </select>
  890. </div>
  891. </div>
  892. </div>
  893. <p className="text-xs text-bambu-gray">
  894. Toggle between dark and light mode using the sun/moon icon in the sidebar.
  895. </p>
  896. </CardContent>
  897. </Card>
  898. <Card>
  899. <CardHeader>
  900. <h2 className="text-lg font-semibold text-white">Archive Settings</h2>
  901. </CardHeader>
  902. <CardContent className="space-y-4">
  903. <div className="flex items-center justify-between">
  904. <div>
  905. <p className="text-white">Auto-archive prints</p>
  906. <p className="text-sm text-bambu-gray">
  907. Automatically save 3MF files when prints complete
  908. </p>
  909. </div>
  910. <label className="relative inline-flex items-center cursor-pointer">
  911. <input
  912. type="checkbox"
  913. checked={localSettings.auto_archive}
  914. onChange={(e) => updateSetting('auto_archive', e.target.checked)}
  915. className="sr-only peer"
  916. />
  917. <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>
  918. </label>
  919. </div>
  920. <div className="flex items-center justify-between">
  921. <div>
  922. <p className="text-white">Save thumbnails</p>
  923. <p className="text-sm text-bambu-gray">
  924. Extract and save preview images from 3MF files
  925. </p>
  926. </div>
  927. <label className="relative inline-flex items-center cursor-pointer">
  928. <input
  929. type="checkbox"
  930. checked={localSettings.save_thumbnails}
  931. onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
  932. className="sr-only peer"
  933. />
  934. <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>
  935. </label>
  936. </div>
  937. <div className="flex items-center justify-between">
  938. <div>
  939. <p className="text-white">Capture finish photo</p>
  940. <p className="text-sm text-bambu-gray">
  941. Take a photo from printer camera when print completes
  942. </p>
  943. </div>
  944. <label className="relative inline-flex items-center cursor-pointer">
  945. <input
  946. type="checkbox"
  947. checked={localSettings.capture_finish_photo}
  948. onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
  949. className="sr-only peer"
  950. />
  951. <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>
  952. </label>
  953. </div>
  954. {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
  955. <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
  956. <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
  957. <div className="text-sm">
  958. <p className="text-yellow-500 font-medium">ffmpeg not installed</p>
  959. <p className="text-bambu-gray mt-1">
  960. Camera capture requires ffmpeg. Install it via{' '}
  961. <code className="bg-bambu-dark-tertiary px-1 rounded">brew install ffmpeg</code> (macOS) or{' '}
  962. <code className="bg-bambu-dark-tertiary px-1 rounded">apt install ffmpeg</code> (Linux).
  963. </p>
  964. </div>
  965. </div>
  966. )}
  967. </CardContent>
  968. </Card>
  969. </div>
  970. {/* Second Column - Camera, Cost, AMS & Spoolman */}
  971. <div className="space-y-6 flex-1 lg:max-w-md">
  972. {/* Camera Settings */}
  973. <Card>
  974. <CardHeader>
  975. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  976. <Video className="w-5 h-5 text-bambu-green" />
  977. Camera
  978. </h2>
  979. </CardHeader>
  980. <CardContent className="space-y-4">
  981. <div>
  982. <label className="block text-sm text-bambu-gray mb-1">
  983. Camera View Mode
  984. </label>
  985. <select
  986. value={localSettings.camera_view_mode ?? 'window'}
  987. onChange={(e) => updateSetting('camera_view_mode', e.target.value as 'window' | 'embedded')}
  988. 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"
  989. >
  990. <option value="window">New Window</option>
  991. <option value="embedded">Embedded Overlay</option>
  992. </select>
  993. <p className="text-xs text-bambu-gray mt-1">
  994. {localSettings.camera_view_mode === 'embedded'
  995. ? 'Camera opens in a resizable overlay on the main screen'
  996. : 'Camera opens in a separate browser window'}
  997. </p>
  998. </div>
  999. {/* External Cameras Section */}
  1000. <div className="border-t border-bambu-dark-tertiary pt-4 mt-4">
  1001. <h3 className="text-sm font-medium text-white mb-2">External Cameras</h3>
  1002. <p className="text-xs text-bambu-gray mb-3">
  1003. Configure external cameras to replace the built-in printer camera. Supports MJPEG streams, RTSP, HTTP snapshots, and USB cameras (V4L2). When enabled, the external camera is used for live view and finish photos.
  1004. </p>
  1005. {printers && printers.length > 0 ? (
  1006. <div className="space-y-3">
  1007. {printers.map(printer => (
  1008. <div key={printer.id} className="p-3 bg-bambu-dark rounded-lg">
  1009. <div className="flex items-center justify-between mb-2">
  1010. <span className="text-white font-medium text-sm">{printer.name}</span>
  1011. <label className="relative inline-flex items-center cursor-pointer">
  1012. <input
  1013. type="checkbox"
  1014. checked={printer.external_camera_enabled}
  1015. onChange={(e) => handleUpdatePrinterCamera(printer.id, { enabled: e.target.checked })}
  1016. className="sr-only peer"
  1017. />
  1018. <div className="w-9 h-5 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:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  1019. </label>
  1020. </div>
  1021. {printer.external_camera_enabled && (
  1022. <div className="space-y-2 mt-2">
  1023. <input
  1024. type="text"
  1025. placeholder={printer.external_camera_type === 'usb' ? 'Device path (/dev/video0)' : 'Camera URL (rtsp://... or http://...)'}
  1026. value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
  1027. onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
  1028. className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  1029. />
  1030. <div className="flex gap-2">
  1031. <select
  1032. value={printer.external_camera_type || 'mjpeg'}
  1033. onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
  1034. className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  1035. >
  1036. <option value="mjpeg">MJPEG Stream</option>
  1037. <option value="rtsp">RTSP Stream</option>
  1038. <option value="snapshot">HTTP Snapshot</option>
  1039. <option value="usb">USB Camera (V4L2)</option>
  1040. </select>
  1041. <Button
  1042. size="sm"
  1043. variant="secondary"
  1044. onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}
  1045. disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}
  1046. >
  1047. {extCameraTestLoading[printer.id] ? (
  1048. <Loader2 className="w-4 h-4 animate-spin" />
  1049. ) : (
  1050. 'Test'
  1051. )}
  1052. </Button>
  1053. </div>
  1054. {extCameraTestResults[printer.id] && (
  1055. <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
  1056. {extCameraTestResults[printer.id]?.success ? (
  1057. <>
  1058. <CheckCircle className="w-3 h-3" />
  1059. Connected{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
  1060. </>
  1061. ) : (
  1062. <>
  1063. <XCircle className="w-3 h-3" />
  1064. {extCameraTestResults[printer.id]?.error || 'Connection failed'}
  1065. </>
  1066. )}
  1067. </div>
  1068. )}
  1069. </div>
  1070. )}
  1071. </div>
  1072. ))}
  1073. </div>
  1074. ) : (
  1075. <p className="text-xs text-bambu-gray italic">No printers configured</p>
  1076. )}
  1077. </div>
  1078. </CardContent>
  1079. </Card>
  1080. <Card>
  1081. <CardHeader>
  1082. <h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
  1083. </CardHeader>
  1084. <CardContent className="space-y-4">
  1085. <div>
  1086. <label className="block text-sm text-bambu-gray mb-1">
  1087. Default filament cost (per kg)
  1088. </label>
  1089. <input
  1090. type="number"
  1091. step="0.01"
  1092. min="0"
  1093. value={localSettings.default_filament_cost}
  1094. onChange={(e) =>
  1095. updateSetting('default_filament_cost', parseFloat(e.target.value) || 0)
  1096. }
  1097. 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"
  1098. />
  1099. </div>
  1100. <div>
  1101. <label className="block text-sm text-bambu-gray mb-1">Currency</label>
  1102. <select
  1103. value={localSettings.currency}
  1104. onChange={(e) => updateSetting('currency', e.target.value)}
  1105. 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"
  1106. >
  1107. <option value="USD">USD ($)</option>
  1108. <option value="EUR">EUR (€)</option>
  1109. <option value="GBP">GBP (£)</option>
  1110. <option value="CHF">CHF (Fr.)</option>
  1111. <option value="JPY">JPY (¥)</option>
  1112. <option value="CNY">CNY (¥)</option>
  1113. <option value="CAD">CAD ($)</option>
  1114. <option value="AUD">AUD ($)</option>
  1115. </select>
  1116. </div>
  1117. <div>
  1118. <label className="block text-sm text-bambu-gray mb-1">
  1119. Electricity cost per kWh
  1120. </label>
  1121. <input
  1122. type="number"
  1123. step="0.01"
  1124. min="0"
  1125. value={localSettings.energy_cost_per_kwh}
  1126. onChange={(e) =>
  1127. updateSetting('energy_cost_per_kwh', parseFloat(e.target.value) || 0)
  1128. }
  1129. 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"
  1130. />
  1131. </div>
  1132. <div>
  1133. <label className="block text-sm text-bambu-gray mb-1">
  1134. Energy display mode
  1135. </label>
  1136. <select
  1137. value={localSettings.energy_tracking_mode || 'total'}
  1138. onChange={(e) => updateSetting('energy_tracking_mode', e.target.value as 'print' | 'total')}
  1139. 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"
  1140. >
  1141. <option value="print">Prints Only</option>
  1142. <option value="total">Total Consumption</option>
  1143. </select>
  1144. <p className="text-xs text-bambu-gray mt-1">
  1145. {localSettings.energy_tracking_mode === 'print'
  1146. ? 'Dashboard shows sum of energy used during prints'
  1147. : 'Dashboard shows lifetime energy from smart plugs'}
  1148. </p>
  1149. </div>
  1150. </CardContent>
  1151. </Card>
  1152. {/* File Manager Settings */}
  1153. <Card>
  1154. <CardHeader>
  1155. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1156. <FileText className="w-5 h-5 text-bambu-green" />
  1157. File Manager
  1158. </h2>
  1159. </CardHeader>
  1160. <CardContent className="space-y-4">
  1161. {/* Archive Mode */}
  1162. <div>
  1163. <label className="block text-sm text-bambu-gray mb-1">
  1164. Create Archive Entry When Printing
  1165. </label>
  1166. <select
  1167. value={localSettings.library_archive_mode ?? 'ask'}
  1168. onChange={(e) => updateSetting('library_archive_mode', e.target.value as 'always' | 'never' | 'ask')}
  1169. 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"
  1170. >
  1171. <option value="always">Always create archive entry</option>
  1172. <option value="never">Never create archive entry</option>
  1173. <option value="ask">Ask each time</option>
  1174. </select>
  1175. <p className="text-xs text-bambu-gray mt-1">
  1176. When printing from File Manager, optionally create an archive entry
  1177. </p>
  1178. </div>
  1179. {/* Disk Space Warning Threshold */}
  1180. <div>
  1181. <label className="block text-sm text-bambu-gray mb-1">
  1182. Low Disk Space Warning
  1183. </label>
  1184. <div className="flex items-center gap-2">
  1185. <input
  1186. type="number"
  1187. min="0.5"
  1188. max="100"
  1189. step="0.5"
  1190. value={localSettings.library_disk_warning_gb ?? 5}
  1191. onChange={(e) => updateSetting('library_disk_warning_gb', parseFloat(e.target.value) || 5)}
  1192. 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"
  1193. />
  1194. <span className="text-bambu-gray">GB</span>
  1195. </div>
  1196. <p className="text-xs text-bambu-gray mt-1">
  1197. Show warning when free disk space falls below this threshold
  1198. </p>
  1199. </div>
  1200. </CardContent>
  1201. </Card>
  1202. </div>
  1203. {/* Third Column - Sidebar Links & Updates */}
  1204. <div className="space-y-6 flex-1 lg:max-w-sm">
  1205. {/* Sidebar Links */}
  1206. <ExternalLinksSettings />
  1207. <Card>
  1208. <CardHeader>
  1209. <h2 className="text-lg font-semibold text-white">Updates</h2>
  1210. </CardHeader>
  1211. <CardContent className="space-y-4">
  1212. <div className="flex items-center justify-between">
  1213. <div>
  1214. <p className="text-white">Check for updates</p>
  1215. <p className="text-sm text-bambu-gray">
  1216. Automatically check for new versions on startup
  1217. </p>
  1218. </div>
  1219. <label className="relative inline-flex items-center cursor-pointer">
  1220. <input
  1221. type="checkbox"
  1222. checked={localSettings.check_updates}
  1223. onChange={(e) => updateSetting('check_updates', e.target.checked)}
  1224. className="sr-only peer"
  1225. />
  1226. <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>
  1227. </label>
  1228. </div>
  1229. <div className="border-t border-bambu-dark-tertiary pt-4">
  1230. <div className="flex items-center justify-between mb-2">
  1231. <div>
  1232. <p className="text-white">Current version</p>
  1233. <p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
  1234. </div>
  1235. <Button
  1236. variant="secondary"
  1237. size="sm"
  1238. onClick={() => refetchUpdateCheck()}
  1239. disabled={isCheckingUpdate}
  1240. >
  1241. {isCheckingUpdate ? (
  1242. <Loader2 className="w-4 h-4 animate-spin" />
  1243. ) : (
  1244. <RefreshCw className="w-4 h-4" />
  1245. )}
  1246. Check now
  1247. </Button>
  1248. </div>
  1249. {updateCheck?.update_available ? (
  1250. <div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
  1251. <div className="flex items-start justify-between">
  1252. <div>
  1253. <p className="text-bambu-green font-medium">
  1254. Update available: v{updateCheck.latest_version}
  1255. </p>
  1256. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  1257. <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
  1258. )}
  1259. </div>
  1260. <div className="flex items-center gap-2">
  1261. {updateCheck.release_notes && (
  1262. <button
  1263. onClick={() => setShowReleaseNotes(true)}
  1264. className="text-bambu-gray hover:text-white transition-colors text-sm underline"
  1265. >
  1266. Release Notes
  1267. </button>
  1268. )}
  1269. {updateCheck.release_url && (
  1270. <a
  1271. href={updateCheck.release_url}
  1272. target="_blank"
  1273. rel="noopener noreferrer"
  1274. className="text-bambu-gray hover:text-white transition-colors"
  1275. title="View release on GitHub"
  1276. >
  1277. <ExternalLink className="w-4 h-4" />
  1278. </a>
  1279. )}
  1280. </div>
  1281. </div>
  1282. {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
  1283. <div className="mt-3">
  1284. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  1285. <Loader2 className="w-4 h-4 animate-spin" />
  1286. <span>{updateStatus.message}</span>
  1287. </div>
  1288. <div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
  1289. <div
  1290. className="bg-bambu-green h-2 rounded-full transition-all duration-300"
  1291. style={{ width: `${updateStatus.progress}%` }}
  1292. />
  1293. </div>
  1294. </div>
  1295. ) : updateStatus?.status === 'complete' ? (
  1296. <div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
  1297. {updateStatus.message}
  1298. </div>
  1299. ) : updateStatus?.status === 'error' ? (
  1300. <div className="mt-3 p-2 bg-red-500/20 rounded text-sm text-red-400">
  1301. {updateStatus.error || updateStatus.message}
  1302. </div>
  1303. ) : updateCheck?.is_docker ? (
  1304. <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
  1305. <p className="text-sm text-bambu-gray mb-2">
  1306. Update via Docker Compose:
  1307. </p>
  1308. <code className="block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono">
  1309. docker compose pull && docker compose up -d
  1310. </code>
  1311. </div>
  1312. ) : (
  1313. <Button
  1314. className="mt-3"
  1315. onClick={() => applyUpdateMutation.mutate()}
  1316. disabled={applyUpdateMutation.isPending}
  1317. >
  1318. {applyUpdateMutation.isPending ? (
  1319. <Loader2 className="w-4 h-4 animate-spin" />
  1320. ) : (
  1321. <Download className="w-4 h-4" />
  1322. )}
  1323. Install Update
  1324. </Button>
  1325. )}
  1326. </div>
  1327. ) : updateCheck?.error ? (
  1328. <div className="mt-2 p-2 bg-red-500/10 border border-red-500/30 rounded text-sm text-red-400">
  1329. Failed to check for updates: {updateCheck.error}
  1330. </div>
  1331. ) : updateCheck && !updateCheck.update_available ? (
  1332. <p className="mt-2 text-sm text-bambu-gray">
  1333. You're running the latest version
  1334. </p>
  1335. ) : null}
  1336. </div>
  1337. </CardContent>
  1338. </Card>
  1339. {/* Data Management */}
  1340. <Card>
  1341. <CardHeader>
  1342. <h2 className="text-lg font-semibold text-white">Data Management</h2>
  1343. </CardHeader>
  1344. <CardContent className="space-y-4">
  1345. <div className="flex items-center justify-between">
  1346. <div>
  1347. <p className="text-white">Clear Notification Logs</p>
  1348. <p className="text-sm text-bambu-gray">
  1349. Delete notification logs older than 30 days
  1350. </p>
  1351. </div>
  1352. <Button
  1353. variant="secondary"
  1354. size="sm"
  1355. onClick={() => setShowClearLogsConfirm(true)}
  1356. >
  1357. <Trash2 className="w-4 h-4" />
  1358. Clear
  1359. </Button>
  1360. </div>
  1361. <div className="flex items-center justify-between">
  1362. <div>
  1363. <p className="text-white">Reset UI Preferences</p>
  1364. <p className="text-sm text-bambu-gray">
  1365. Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.
  1366. </p>
  1367. </div>
  1368. <Button
  1369. variant="secondary"
  1370. size="sm"
  1371. onClick={() => setShowClearStorageConfirm(true)}
  1372. >
  1373. <Trash2 className="w-4 h-4" />
  1374. Reset
  1375. </Button>
  1376. </div>
  1377. <div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
  1378. <div>
  1379. <p className="text-white">Backup & Restore</p>
  1380. <p className="text-sm text-bambu-gray">
  1381. Export/import settings and configure GitHub backup
  1382. </p>
  1383. </div>
  1384. <Button
  1385. variant="secondary"
  1386. size="sm"
  1387. onClick={() => handleTabChange('backup')}
  1388. >
  1389. <Database className="w-4 h-4" />
  1390. Go to Backup
  1391. </Button>
  1392. </div>
  1393. </CardContent>
  1394. </Card>
  1395. </div>
  1396. </div>
  1397. )}
  1398. {/* Network Tab */}
  1399. {activeTab === 'network' && localSettings && (
  1400. <div className="flex flex-col lg:flex-row gap-6">
  1401. {/* Left Column - External URL & FTP Retry */}
  1402. <div className="flex-1 lg:max-w-xl space-y-4">
  1403. {/* External URL */}
  1404. <Card>
  1405. <CardHeader>
  1406. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1407. <Globe className="w-5 h-5 text-blue-400" />
  1408. External URL
  1409. </h2>
  1410. </CardHeader>
  1411. <CardContent className="space-y-4">
  1412. <p className="text-sm text-bambu-gray">
  1413. The external URL where Bambuddy is accessible. Used for notification images and external integrations.
  1414. </p>
  1415. <div>
  1416. <label className="block text-sm text-bambu-gray mb-1">
  1417. Bambuddy URL
  1418. </label>
  1419. <input
  1420. type="text"
  1421. value={localSettings.external_url ?? ''}
  1422. onChange={(e) => updateSetting('external_url', e.target.value)}
  1423. placeholder="http://192.168.1.100:8000"
  1424. 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"
  1425. />
  1426. <p className="text-xs text-bambu-gray mt-1">
  1427. Include protocol and port (e.g., http://192.168.1.100:8000)
  1428. </p>
  1429. </div>
  1430. </CardContent>
  1431. </Card>
  1432. <Card>
  1433. <CardHeader>
  1434. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1435. <RefreshCw className="w-5 h-5 text-blue-400" />
  1436. FTP Retry
  1437. </h2>
  1438. </CardHeader>
  1439. <CardContent className="space-y-4">
  1440. <p className="text-sm text-bambu-gray">
  1441. Retry FTP operations when printer WiFi is unreliable. Applies to 3MF downloads, print uploads, timelapse downloads, and firmware updates.
  1442. </p>
  1443. <div className="flex items-center justify-between">
  1444. <div>
  1445. <p className="text-white">Enable retry</p>
  1446. <p className="text-sm text-bambu-gray">
  1447. Automatically retry failed FTP operations
  1448. </p>
  1449. </div>
  1450. <label className="relative inline-flex items-center cursor-pointer">
  1451. <input
  1452. type="checkbox"
  1453. checked={localSettings.ftp_retry_enabled ?? true}
  1454. onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
  1455. className="sr-only peer"
  1456. />
  1457. <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>
  1458. </label>
  1459. </div>
  1460. {localSettings.ftp_retry_enabled && (
  1461. <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
  1462. <div>
  1463. <label className="block text-sm text-bambu-gray mb-1">
  1464. Retry attempts
  1465. </label>
  1466. <div className="flex items-center gap-2">
  1467. <input
  1468. type="number"
  1469. min="1"
  1470. max="10"
  1471. value={localSettings.ftp_retry_count ?? 3}
  1472. onChange={(e) => updateSetting('ftp_retry_count', Math.min(10, Math.max(1, parseInt(e.target.value) || 3)))}
  1473. 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"
  1474. />
  1475. <span className="text-bambu-gray">times</span>
  1476. </div>
  1477. <p className="text-xs text-bambu-gray mt-1">
  1478. Number of retry attempts before giving up (1-10)
  1479. </p>
  1480. </div>
  1481. <div>
  1482. <label className="block text-sm text-bambu-gray mb-1">
  1483. Retry delay
  1484. </label>
  1485. <div className="flex items-center gap-2">
  1486. <input
  1487. type="number"
  1488. min="1"
  1489. max="30"
  1490. value={localSettings.ftp_retry_delay ?? 2}
  1491. onChange={(e) => updateSetting('ftp_retry_delay', Math.min(30, Math.max(1, parseInt(e.target.value) || 2)))}
  1492. 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"
  1493. />
  1494. <span className="text-bambu-gray">seconds</span>
  1495. </div>
  1496. <p className="text-xs text-bambu-gray mt-1">
  1497. Wait time between retries (1-30)
  1498. </p>
  1499. </div>
  1500. </div>
  1501. )}
  1502. <div className="pt-2 border-t border-bambu-dark-tertiary">
  1503. <label className="block text-sm text-bambu-gray mb-1">
  1504. Connection timeout
  1505. </label>
  1506. <div className="flex items-center gap-2">
  1507. <input
  1508. type="number"
  1509. min="10"
  1510. max="120"
  1511. value={localSettings.ftp_timeout ?? 30}
  1512. onChange={(e) => updateSetting('ftp_timeout', Math.min(120, Math.max(10, parseInt(e.target.value) || 30)))}
  1513. 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"
  1514. />
  1515. <span className="text-bambu-gray">seconds</span>
  1516. </div>
  1517. <p className="text-xs text-bambu-gray mt-1">
  1518. Socket timeout for slow connections. Increase for A1/A1 Mini printers with weak WiFi (10-120)
  1519. </p>
  1520. </div>
  1521. </CardContent>
  1522. </Card>
  1523. </div>
  1524. {/* Right Column - Home Assistant & MQTT Publishing */}
  1525. <div className="flex-1 lg:max-w-xl space-y-4">
  1526. {/* Home Assistant Integration */}
  1527. <Card>
  1528. <CardHeader>
  1529. <div className="flex items-center justify-between">
  1530. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1531. <Home className="w-5 h-5 text-bambu-green" />
  1532. Home Assistant
  1533. </h2>
  1534. {localSettings.ha_enabled && haTestResult && (
  1535. <div className="flex items-center gap-2">
  1536. <span className={`w-2.5 h-2.5 rounded-full ${haTestResult.success ? 'bg-green-400' : 'bg-red-400'}`} />
  1537. <span className={`text-sm ${haTestResult.success ? 'text-green-400' : 'text-red-400'}`}>
  1538. {haTestResult.success ? 'Connected' : 'Disconnected'}
  1539. </span>
  1540. </div>
  1541. )}
  1542. </div>
  1543. </CardHeader>
  1544. <CardContent className="space-y-4">
  1545. <p className="text-sm text-bambu-gray">
  1546. Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities.
  1547. </p>
  1548. <div className="flex items-center justify-between">
  1549. <div>
  1550. <p className="text-white">Enable Home Assistant</p>
  1551. <p className="text-xs text-bambu-gray">Control smart plugs via Home Assistant</p>
  1552. </div>
  1553. <label className="relative inline-flex items-center cursor-pointer">
  1554. <input
  1555. type="checkbox"
  1556. checked={localSettings.ha_enabled ?? false}
  1557. onChange={(e) => updateSetting('ha_enabled', e.target.checked)}
  1558. className="sr-only peer"
  1559. />
  1560. <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>
  1561. </label>
  1562. </div>
  1563. {localSettings.ha_enabled && (
  1564. <>
  1565. <div>
  1566. <label className="block text-sm text-bambu-gray mb-1">
  1567. Home Assistant URL
  1568. </label>
  1569. <input
  1570. type="text"
  1571. value={localSettings.ha_url ?? ''}
  1572. onChange={(e) => updateSetting('ha_url', e.target.value)}
  1573. placeholder="http://192.168.1.100:8123"
  1574. 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"
  1575. />
  1576. </div>
  1577. <div>
  1578. <label className="block text-sm text-bambu-gray mb-1">
  1579. Long-Lived Access Token
  1580. </label>
  1581. <input
  1582. type="password"
  1583. value={localSettings.ha_token ?? ''}
  1584. onChange={(e) => updateSetting('ha_token', e.target.value)}
  1585. placeholder="eyJ0eXAiOiJKV1QiLC..."
  1586. 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"
  1587. />
  1588. <p className="text-xs text-bambu-gray mt-1">
  1589. Create a token in HA: Profile → Long-Lived Access Tokens → Create Token
  1590. </p>
  1591. </div>
  1592. {localSettings.ha_url && localSettings.ha_token && (
  1593. <div className="pt-2 border-t border-bambu-dark-tertiary">
  1594. <Button
  1595. variant="secondary"
  1596. size="sm"
  1597. disabled={haTestLoading}
  1598. onClick={async () => {
  1599. setHaTestLoading(true);
  1600. setHaTestResult(null);
  1601. try {
  1602. const result = await api.testHAConnection(localSettings.ha_url!, localSettings.ha_token!);
  1603. setHaTestResult(result);
  1604. } catch (e) {
  1605. setHaTestResult({ success: false, message: null, error: e instanceof Error ? e.message : 'Unknown error' });
  1606. } finally {
  1607. setHaTestLoading(false);
  1608. }
  1609. }}
  1610. >
  1611. {haTestLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wifi className="w-4 h-4" />}
  1612. Test Connection
  1613. </Button>
  1614. </div>
  1615. )}
  1616. </>
  1617. )}
  1618. </CardContent>
  1619. </Card>
  1620. {/* MQTT Publishing */}
  1621. <Card>
  1622. <CardHeader>
  1623. <div className="flex items-center justify-between">
  1624. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1625. <Wifi className="w-5 h-5 text-blue-400" />
  1626. MQTT Publishing
  1627. </h2>
  1628. {mqttStatus?.enabled && (
  1629. <div className="flex items-center gap-2">
  1630. <span className={`w-2.5 h-2.5 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />
  1631. <span className={`text-sm ${mqttStatus.connected ? 'text-green-400' : 'text-red-400'}`}>
  1632. {mqttStatus.connected ? 'Connected' : 'Disconnected'}
  1633. </span>
  1634. </div>
  1635. )}
  1636. </div>
  1637. </CardHeader>
  1638. <CardContent className="space-y-4">
  1639. <p className="text-sm text-bambu-gray">
  1640. Publish BamBuddy events to an external MQTT broker for integration with Node-RED, Home Assistant, and other automation systems.
  1641. </p>
  1642. <div className="flex items-center justify-between">
  1643. <div>
  1644. <p className="text-white">Enable MQTT</p>
  1645. <p className="text-sm text-bambu-gray">
  1646. Publish events to external MQTT broker
  1647. </p>
  1648. </div>
  1649. <label className="relative inline-flex items-center cursor-pointer">
  1650. <input
  1651. type="checkbox"
  1652. checked={localSettings.mqtt_enabled ?? false}
  1653. onChange={(e) => updateSetting('mqtt_enabled', e.target.checked)}
  1654. className="sr-only peer"
  1655. />
  1656. <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>
  1657. </label>
  1658. </div>
  1659. {localSettings.mqtt_enabled && (
  1660. <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
  1661. <div>
  1662. <label className="block text-sm text-bambu-gray mb-1">
  1663. Broker hostname
  1664. </label>
  1665. <input
  1666. type="text"
  1667. value={localSettings.mqtt_broker ?? ''}
  1668. onChange={(e) => updateSetting('mqtt_broker', e.target.value)}
  1669. placeholder="mqtt.example.com or 192.168.1.100"
  1670. 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"
  1671. />
  1672. </div>
  1673. <div className="flex items-end gap-4">
  1674. <div className="flex-1">
  1675. <label className="block text-sm text-bambu-gray mb-1">
  1676. Port
  1677. </label>
  1678. <input
  1679. type="number"
  1680. min="1"
  1681. max="65535"
  1682. value={localSettings.mqtt_port ?? 1883}
  1683. onChange={(e) => updateSetting('mqtt_port', Math.min(65535, Math.max(1, parseInt(e.target.value) || 1883)))}
  1684. 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"
  1685. />
  1686. </div>
  1687. <div className="flex items-center gap-3 pb-2">
  1688. <label className="relative inline-flex items-center cursor-pointer">
  1689. <input
  1690. type="checkbox"
  1691. checked={localSettings.mqtt_use_tls ?? false}
  1692. onChange={(e) => {
  1693. const useTls = e.target.checked;
  1694. updateSetting('mqtt_use_tls', useTls);
  1695. // Auto-populate port based on TLS selection
  1696. const currentPort = localSettings.mqtt_port ?? 1883;
  1697. if (useTls && currentPort === 1883) {
  1698. updateSetting('mqtt_port', 8883);
  1699. } else if (!useTls && currentPort === 8883) {
  1700. updateSetting('mqtt_port', 1883);
  1701. }
  1702. }}
  1703. className="sr-only peer"
  1704. />
  1705. <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>
  1706. </label>
  1707. <span className="text-white text-sm">Use TLS</span>
  1708. </div>
  1709. </div>
  1710. <div>
  1711. <label className="block text-sm text-bambu-gray mb-1">
  1712. Username (optional)
  1713. </label>
  1714. <input
  1715. type="text"
  1716. value={localSettings.mqtt_username ?? ''}
  1717. onChange={(e) => updateSetting('mqtt_username', e.target.value)}
  1718. placeholder="Leave empty for anonymous"
  1719. 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"
  1720. />
  1721. </div>
  1722. <div>
  1723. <label className="block text-sm text-bambu-gray mb-1">
  1724. Password (optional)
  1725. </label>
  1726. <input
  1727. type="password"
  1728. value={localSettings.mqtt_password ?? ''}
  1729. onChange={(e) => updateSetting('mqtt_password', e.target.value)}
  1730. placeholder="Leave empty for anonymous"
  1731. 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"
  1732. />
  1733. </div>
  1734. <div>
  1735. <label className="block text-sm text-bambu-gray mb-1">
  1736. Topic prefix
  1737. </label>
  1738. <input
  1739. type="text"
  1740. value={localSettings.mqtt_topic_prefix ?? 'bambuddy'}
  1741. onChange={(e) => updateSetting('mqtt_topic_prefix', e.target.value)}
  1742. placeholder="bambuddy"
  1743. 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"
  1744. />
  1745. <p className="text-xs text-bambu-gray mt-1">
  1746. Topics will be: {localSettings.mqtt_topic_prefix || 'bambuddy'}/printers/&lt;serial&gt;/status, etc.
  1747. </p>
  1748. </div>
  1749. {/* Connection Info */}
  1750. {mqttStatus && (
  1751. <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary">
  1752. <div className="flex items-center gap-2 text-sm">
  1753. <span className={`w-2 h-2 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />
  1754. <span className="text-bambu-gray">
  1755. {mqttStatus.connected ? (
  1756. <>Connected to <span className="text-white">{mqttStatus.broker}:{mqttStatus.port}</span></>
  1757. ) : (
  1758. 'Not connected'
  1759. )}
  1760. </span>
  1761. </div>
  1762. </div>
  1763. )}
  1764. </div>
  1765. )}
  1766. </CardContent>
  1767. </Card>
  1768. </div>
  1769. {/* Third Column - Prometheus Metrics */}
  1770. <div className="flex-1 lg:max-w-md space-y-4">
  1771. <Card>
  1772. <CardHeader>
  1773. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1774. <TrendingUp className="w-5 h-5 text-orange-400" />
  1775. Prometheus Metrics
  1776. </h2>
  1777. </CardHeader>
  1778. <CardContent className="space-y-4">
  1779. <p className="text-sm text-bambu-gray">
  1780. Expose printer metrics at <code className="bg-bambu-dark px-1 rounded">/api/v1/metrics</code> for Prometheus/Grafana monitoring.
  1781. </p>
  1782. <div className="flex items-center justify-between">
  1783. <div>
  1784. <p className="text-white">Enable Metrics Endpoint</p>
  1785. <p className="text-xs text-bambu-gray">Expose printer data in Prometheus format</p>
  1786. </div>
  1787. <label className="relative inline-flex items-center cursor-pointer">
  1788. <input
  1789. type="checkbox"
  1790. checked={localSettings.prometheus_enabled ?? false}
  1791. onChange={(e) => updateSetting('prometheus_enabled', e.target.checked)}
  1792. className="sr-only peer"
  1793. />
  1794. <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>
  1795. </label>
  1796. </div>
  1797. {localSettings.prometheus_enabled && (
  1798. <div className="space-y-4 pt-2 border-t border-bambu-dark-tertiary">
  1799. <div>
  1800. <label className="block text-sm text-bambu-gray mb-1">
  1801. Bearer Token (optional)
  1802. </label>
  1803. <input
  1804. type="password"
  1805. value={localSettings.prometheus_token ?? ''}
  1806. onChange={(e) => updateSetting('prometheus_token', e.target.value)}
  1807. placeholder="Leave empty for no authentication"
  1808. 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"
  1809. />
  1810. <p className="text-xs text-bambu-gray mt-1">
  1811. If set, requests must include <code className="bg-bambu-dark px-1 rounded">Authorization: Bearer &lt;token&gt;</code>
  1812. </p>
  1813. </div>
  1814. <div className="pt-2 border-t border-bambu-dark-tertiary">
  1815. <p className="text-sm text-white mb-2">Available Metrics</p>
  1816. <div className="text-xs text-bambu-gray space-y-1">
  1817. <p><code className="text-orange-400">bambuddy_printer_connected</code> - Connection status</p>
  1818. <p><code className="text-orange-400">bambuddy_printer_state</code> - Printer state (idle/printing/etc)</p>
  1819. <p><code className="text-orange-400">bambuddy_print_progress</code> - Print progress 0-100%</p>
  1820. <p><code className="text-orange-400">bambuddy_bed_temp_celsius</code> - Bed temperature</p>
  1821. <p><code className="text-orange-400">bambuddy_nozzle_temp_celsius</code> - Nozzle temperature</p>
  1822. <p><code className="text-orange-400">bambuddy_prints_total</code> - Total prints by result</p>
  1823. <p className="text-bambu-gray/70 italic">...and more (layers, fans, queue, filament usage)</p>
  1824. </div>
  1825. </div>
  1826. </div>
  1827. )}
  1828. </CardContent>
  1829. </Card>
  1830. </div>
  1831. </div>
  1832. )}
  1833. {/* Home Assistant Test Connection Modal */}
  1834. {haTestResult && (
  1835. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
  1836. <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full mx-4">
  1837. <div className="flex items-center gap-3 mb-4">
  1838. {haTestResult.success ? (
  1839. <CheckCircle className="w-8 h-8 text-green-400" />
  1840. ) : (
  1841. <XCircle className="w-8 h-8 text-red-400" />
  1842. )}
  1843. <h3 className="text-lg font-medium text-white">
  1844. {haTestResult.success ? 'Connection Successful' : 'Connection Failed'}
  1845. </h3>
  1846. </div>
  1847. <p className="text-bambu-gray mb-6">
  1848. {haTestResult.success
  1849. ? haTestResult.message || 'Successfully connected to Home Assistant.'
  1850. : haTestResult.error || 'Failed to connect to Home Assistant.'}
  1851. </p>
  1852. <div className="flex justify-end">
  1853. <Button
  1854. variant="primary"
  1855. onClick={() => setHaTestResult(null)}
  1856. >
  1857. OK
  1858. </Button>
  1859. </div>
  1860. </div>
  1861. </div>
  1862. )}
  1863. {/* Smart Plugs Tab */}
  1864. {activeTab === 'plugs' && (
  1865. <div className="max-w-4xl">
  1866. <div className="flex items-start justify-between mb-6">
  1867. <div>
  1868. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1869. <Plug className="w-5 h-5 text-bambu-green" />
  1870. Smart Plugs
  1871. </h2>
  1872. <p className="text-sm text-bambu-gray mt-1">
  1873. Connect smart plugs (Tasmota or Home Assistant) to automate power control and track energy usage for your printers.
  1874. </p>
  1875. </div>
  1876. <div className="flex items-center gap-2 pt-1 shrink-0">
  1877. {smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (
  1878. <>
  1879. <Button
  1880. variant="secondary"
  1881. size="sm"
  1882. className="whitespace-nowrap"
  1883. onClick={() => setShowBulkPlugConfirm('on')}
  1884. disabled={bulkPlugActionMutation.isPending}
  1885. title="Turn all plugs on"
  1886. >
  1887. {bulkPlugActionMutation.isPending ? (
  1888. <Loader2 className="w-4 h-4 animate-spin" />
  1889. ) : (
  1890. <Power className="w-4 h-4 text-bambu-green" />
  1891. )}
  1892. All On
  1893. </Button>
  1894. <Button
  1895. variant="secondary"
  1896. size="sm"
  1897. className="whitespace-nowrap"
  1898. onClick={() => setShowBulkPlugConfirm('off')}
  1899. disabled={bulkPlugActionMutation.isPending}
  1900. title="Turn all plugs off"
  1901. >
  1902. {bulkPlugActionMutation.isPending ? (
  1903. <Loader2 className="w-4 h-4 animate-spin" />
  1904. ) : (
  1905. <PowerOff className="w-4 h-4 text-red-400" />
  1906. )}
  1907. All Off
  1908. </Button>
  1909. </>
  1910. )}
  1911. <Button
  1912. className="whitespace-nowrap"
  1913. onClick={() => {
  1914. setEditingPlug(null);
  1915. setShowPlugModal(true);
  1916. }}
  1917. >
  1918. <Plus className="w-4 h-4" />
  1919. Add Smart Plug
  1920. </Button>
  1921. </div>
  1922. </div>
  1923. {/* Energy Summary Card */}
  1924. {smartPlugs && smartPlugs.length > 0 && (
  1925. <Card className="mb-6">
  1926. <CardHeader>
  1927. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  1928. <Zap className="w-4 h-4 text-yellow-400" />
  1929. Energy Summary
  1930. {energyLoading && (
  1931. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray ml-2" />
  1932. )}
  1933. </h3>
  1934. </CardHeader>
  1935. <CardContent>
  1936. {plugEnergySummary ? (
  1937. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  1938. {/* Current Power */}
  1939. <div className="bg-bambu-dark rounded-lg p-3">
  1940. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1941. <Zap className="w-3 h-3" />
  1942. Current Power
  1943. </div>
  1944. <div className="text-xl font-bold text-white">
  1945. {plugEnergySummary.totalPower.toFixed(1)}
  1946. <span className="text-sm font-normal text-bambu-gray ml-1">W</span>
  1947. </div>
  1948. <div className="text-xs text-bambu-gray mt-1">
  1949. {plugEnergySummary.reachableCount}/{plugEnergySummary.totalPlugs} plugs online
  1950. </div>
  1951. </div>
  1952. {/* Today */}
  1953. <div className="bg-bambu-dark rounded-lg p-3">
  1954. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1955. <Calendar className="w-3 h-3" />
  1956. Today
  1957. </div>
  1958. <div className="text-xl font-bold text-white">
  1959. {plugEnergySummary.totalToday.toFixed(2)}
  1960. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1961. </div>
  1962. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1963. <div className="text-xs text-bambu-gray mt-1">
  1964. ~{(plugEnergySummary.totalToday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1965. </div>
  1966. )}
  1967. </div>
  1968. {/* Yesterday */}
  1969. <div className="bg-bambu-dark rounded-lg p-3">
  1970. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1971. <TrendingUp className="w-3 h-3" />
  1972. Yesterday
  1973. </div>
  1974. <div className="text-xl font-bold text-white">
  1975. {plugEnergySummary.totalYesterday.toFixed(2)}
  1976. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1977. </div>
  1978. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1979. <div className="text-xs text-bambu-gray mt-1">
  1980. ~{(plugEnergySummary.totalYesterday * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1981. </div>
  1982. )}
  1983. </div>
  1984. {/* Total Lifetime */}
  1985. <div className="bg-bambu-dark rounded-lg p-3">
  1986. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  1987. <DollarSign className="w-3 h-3" />
  1988. Total
  1989. </div>
  1990. <div className="text-xl font-bold text-white">
  1991. {plugEnergySummary.totalLifetime.toFixed(1)}
  1992. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  1993. </div>
  1994. {localSettings && localSettings.energy_cost_per_kwh > 0 && (
  1995. <div className="text-xs text-bambu-gray mt-1">
  1996. ~{(plugEnergySummary.totalLifetime * localSettings.energy_cost_per_kwh).toFixed(2)} {localSettings.currency}
  1997. </div>
  1998. )}
  1999. </div>
  2000. </div>
  2001. ) : !energyLoading ? (
  2002. <p className="text-sm text-bambu-gray">
  2003. Enable plugs to see energy summary
  2004. </p>
  2005. ) : null}
  2006. </CardContent>
  2007. </Card>
  2008. )}
  2009. {plugsLoading ? (
  2010. <div className="flex justify-center py-12">
  2011. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  2012. </div>
  2013. ) : smartPlugs && smartPlugs.length > 0 ? (
  2014. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  2015. {smartPlugs.map((plug) => (
  2016. <SmartPlugCard
  2017. key={plug.id}
  2018. plug={plug}
  2019. onEdit={(p) => {
  2020. setEditingPlug(p);
  2021. setShowPlugModal(true);
  2022. }}
  2023. />
  2024. ))}
  2025. </div>
  2026. ) : (
  2027. <Card>
  2028. <CardContent className="py-12">
  2029. <div className="text-center text-bambu-gray">
  2030. <Plug className="w-16 h-16 mx-auto mb-4 opacity-30" />
  2031. <p className="text-lg font-medium text-white mb-2">No smart plugs configured</p>
  2032. <p className="text-sm mb-4">Add a Tasmota-based smart plug to track energy usage and automate power control.</p>
  2033. <Button
  2034. onClick={() => {
  2035. setEditingPlug(null);
  2036. setShowPlugModal(true);
  2037. }}
  2038. >
  2039. <Plus className="w-4 h-4" />
  2040. Add Your First Smart Plug
  2041. </Button>
  2042. </div>
  2043. </CardContent>
  2044. </Card>
  2045. )}
  2046. </div>
  2047. )}
  2048. {/* Notifications Tab */}
  2049. {activeTab === 'notifications' && (
  2050. <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
  2051. {/* Left Column: Providers */}
  2052. <div>
  2053. <div className="flex items-center justify-between mb-4">
  2054. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2055. <Bell className="w-5 h-5 text-bambu-green" />
  2056. Providers
  2057. </h2>
  2058. <div className="flex items-center gap-2">
  2059. <Button
  2060. size="sm"
  2061. variant="secondary"
  2062. onClick={() => setShowLogViewer(true)}
  2063. >
  2064. <History className="w-4 h-4" />
  2065. Log
  2066. </Button>
  2067. {notificationProviders && notificationProviders.length > 0 && (
  2068. <Button
  2069. size="sm"
  2070. variant="secondary"
  2071. onClick={() => {
  2072. setTestAllResult(null);
  2073. testAllMutation.mutate();
  2074. }}
  2075. disabled={testAllMutation.isPending}
  2076. >
  2077. {testAllMutation.isPending ? (
  2078. <Loader2 className="w-4 h-4 animate-spin" />
  2079. ) : (
  2080. <Send className="w-4 h-4" />
  2081. )}
  2082. Test All
  2083. </Button>
  2084. )}
  2085. <Button
  2086. size="sm"
  2087. onClick={() => {
  2088. setEditingProvider(null);
  2089. setShowNotificationModal(true);
  2090. }}
  2091. >
  2092. <Plus className="w-4 h-4" />
  2093. Add
  2094. </Button>
  2095. </div>
  2096. </div>
  2097. {/* Notification Language Setting */}
  2098. <Card className="mb-4">
  2099. <CardContent className="py-3">
  2100. <div className="flex items-center justify-between">
  2101. <div>
  2102. <p className="text-white text-sm font-medium">{t('settings.notificationLanguage')}</p>
  2103. <p className="text-xs text-bambu-gray">{t('settings.notificationLanguageDescription')}</p>
  2104. </div>
  2105. <select
  2106. value={localSettings.notification_language || 'en'}
  2107. onChange={(e) => updateSetting('notification_language', e.target.value)}
  2108. 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"
  2109. >
  2110. {availableLanguages.map((lang) => (
  2111. <option key={lang.code} value={lang.code}>
  2112. {lang.nativeName}
  2113. </option>
  2114. ))}
  2115. </select>
  2116. </div>
  2117. </CardContent>
  2118. </Card>
  2119. {/* Test All Results */}
  2120. {testAllResult && (
  2121. <Card className="mb-4">
  2122. <CardContent className="py-3">
  2123. <div className="flex items-center justify-between mb-2">
  2124. <span className="text-sm font-medium text-white">Test Results</span>
  2125. <button
  2126. onClick={() => setTestAllResult(null)}
  2127. className="text-bambu-gray hover:text-white text-xs"
  2128. >
  2129. Dismiss
  2130. </button>
  2131. </div>
  2132. <div className="flex items-center gap-4 text-sm mb-2">
  2133. <span className="flex items-center gap-1 text-bambu-green">
  2134. <CheckCircle className="w-4 h-4" />
  2135. {testAllResult.success} passed
  2136. </span>
  2137. {testAllResult.failed > 0 && (
  2138. <span className="flex items-center gap-1 text-red-400">
  2139. <XCircle className="w-4 h-4" />
  2140. {testAllResult.failed} failed
  2141. </span>
  2142. )}
  2143. </div>
  2144. {testAllResult.results.filter(r => !r.success).length > 0 && (
  2145. <div className="space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary">
  2146. {testAllResult.results.filter(r => !r.success).map((result) => (
  2147. <div key={result.provider_id} className="text-xs text-red-400">
  2148. <span className="font-medium">{result.provider_name}:</span> {result.message}
  2149. </div>
  2150. ))}
  2151. </div>
  2152. )}
  2153. </CardContent>
  2154. </Card>
  2155. )}
  2156. {providersLoading ? (
  2157. <div className="flex justify-center py-12">
  2158. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  2159. </div>
  2160. ) : notificationProviders && notificationProviders.length > 0 ? (
  2161. <div className="space-y-3">
  2162. {notificationProviders.map((provider) => (
  2163. <NotificationProviderCard
  2164. key={provider.id}
  2165. provider={provider}
  2166. onEdit={(p) => {
  2167. setEditingProvider(p);
  2168. setShowNotificationModal(true);
  2169. }}
  2170. />
  2171. ))}
  2172. </div>
  2173. ) : (
  2174. <Card>
  2175. <CardContent className="py-8">
  2176. <div className="text-center text-bambu-gray">
  2177. <Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
  2178. <p className="text-sm font-medium text-white mb-2">No providers configured</p>
  2179. <p className="text-xs mb-3">Add a provider to receive alerts.</p>
  2180. <Button
  2181. size="sm"
  2182. onClick={() => {
  2183. setEditingProvider(null);
  2184. setShowNotificationModal(true);
  2185. }}
  2186. >
  2187. <Plus className="w-4 h-4" />
  2188. Add Provider
  2189. </Button>
  2190. </div>
  2191. </CardContent>
  2192. </Card>
  2193. )}
  2194. </div>
  2195. {/* Right Column: Templates */}
  2196. <div>
  2197. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-4">
  2198. <FileText className="w-5 h-5 text-bambu-green" />
  2199. Message Templates
  2200. </h2>
  2201. <p className="text-sm text-bambu-gray mb-4">
  2202. Customize notification messages for each event.
  2203. </p>
  2204. {templatesLoading ? (
  2205. <div className="flex justify-center py-8">
  2206. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  2207. </div>
  2208. ) : notificationTemplates && notificationTemplates.length > 0 ? (
  2209. <div className="space-y-2">
  2210. {notificationTemplates.map((template) => (
  2211. <Card
  2212. key={template.id}
  2213. className="cursor-pointer hover:border-bambu-green/50 transition-colors"
  2214. onClick={() => setEditingTemplate(template)}
  2215. >
  2216. <CardContent className="py-2.5 px-3">
  2217. <div className="flex items-center justify-between">
  2218. <div className="min-w-0 flex-1">
  2219. <p className="text-white font-medium text-sm truncate">{template.name}</p>
  2220. <p className="text-bambu-gray text-xs truncate mt-0.5">
  2221. {template.title_template}
  2222. </p>
  2223. </div>
  2224. <button
  2225. className="p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2"
  2226. onClick={(e) => {
  2227. e.stopPropagation();
  2228. setEditingTemplate(template);
  2229. }}
  2230. >
  2231. <Edit2 className="w-4 h-4 text-bambu-gray" />
  2232. </button>
  2233. </div>
  2234. </CardContent>
  2235. </Card>
  2236. ))}
  2237. </div>
  2238. ) : (
  2239. <Card>
  2240. <CardContent className="py-8">
  2241. <div className="text-center text-bambu-gray">
  2242. <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
  2243. <p className="text-sm">No templates available. Restart the backend to seed default templates.</p>
  2244. </div>
  2245. </CardContent>
  2246. </Card>
  2247. )}
  2248. </div>
  2249. </div>
  2250. )}
  2251. {/* API Keys Tab */}
  2252. {activeTab === 'apikeys' && (
  2253. <div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
  2254. {/* Left Column - API Keys Management */}
  2255. <div>
  2256. <div className="flex items-start justify-between gap-4 mb-6">
  2257. <div className="flex-1">
  2258. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2259. <Key className="w-5 h-5 text-bambu-green" />
  2260. API Keys
  2261. </h2>
  2262. <p className="text-sm text-bambu-gray mt-1">
  2263. Create API keys for external integrations and webhooks.
  2264. </p>
  2265. </div>
  2266. <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
  2267. <Plus className="w-4 h-4" />
  2268. Create Key
  2269. </Button>
  2270. </div>
  2271. {/* Created Key Display */}
  2272. {createdAPIKey && (
  2273. <Card className="mb-6 border-bambu-green">
  2274. <CardContent className="py-4">
  2275. <div className="flex items-start gap-3">
  2276. <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
  2277. <div className="flex-1">
  2278. <p className="text-white font-medium mb-1">API Key Created Successfully</p>
  2279. <p className="text-sm text-bambu-gray mb-2">
  2280. Copy this key now - it won't be shown again!
  2281. </p>
  2282. <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
  2283. <code className="flex-1 text-sm text-bambu-green font-mono break-all">
  2284. {createdAPIKey}
  2285. </code>
  2286. <Button
  2287. variant="secondary"
  2288. size="sm"
  2289. onClick={async () => {
  2290. try {
  2291. if (navigator.clipboard && navigator.clipboard.writeText) {
  2292. await navigator.clipboard.writeText(createdAPIKey);
  2293. } else {
  2294. const textArea = document.createElement('textarea');
  2295. textArea.value = createdAPIKey;
  2296. textArea.style.position = 'fixed';
  2297. textArea.style.left = '-999999px';
  2298. document.body.appendChild(textArea);
  2299. textArea.select();
  2300. document.execCommand('copy');
  2301. document.body.removeChild(textArea);
  2302. }
  2303. showToast('Key copied to clipboard');
  2304. } catch {
  2305. showToast('Failed to copy key', 'error');
  2306. }
  2307. }}
  2308. >
  2309. <Copy className="w-4 h-4" />
  2310. </Button>
  2311. </div>
  2312. <div className="flex gap-2 mt-3">
  2313. <Button
  2314. variant="secondary"
  2315. size="sm"
  2316. onClick={() => {
  2317. setTestApiKey(createdAPIKey);
  2318. showToast('Key added to API Browser');
  2319. }}
  2320. >
  2321. Use in API Browser
  2322. </Button>
  2323. <Button
  2324. variant="secondary"
  2325. size="sm"
  2326. onClick={() => setCreatedAPIKey(null)}
  2327. >
  2328. Dismiss
  2329. </Button>
  2330. </div>
  2331. </div>
  2332. </div>
  2333. </CardContent>
  2334. </Card>
  2335. )}
  2336. {/* Create Key Form */}
  2337. {showCreateAPIKey && (
  2338. <Card className="mb-6">
  2339. <CardHeader>
  2340. <h3 className="text-base font-semibold text-white">Create New API Key</h3>
  2341. </CardHeader>
  2342. <CardContent className="space-y-4">
  2343. <div>
  2344. <label className="block text-sm text-bambu-gray mb-1">Key Name</label>
  2345. <input
  2346. type="text"
  2347. value={newAPIKeyName}
  2348. onChange={(e) => setNewAPIKeyName(e.target.value)}
  2349. placeholder="e.g., Home Assistant, OctoPrint"
  2350. 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"
  2351. />
  2352. </div>
  2353. <div>
  2354. <label className="block text-sm text-bambu-gray mb-2">Permissions</label>
  2355. <div className="space-y-2">
  2356. <label className="flex items-center gap-3 cursor-pointer">
  2357. <input
  2358. type="checkbox"
  2359. checked={newAPIKeyPermissions.can_read_status}
  2360. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
  2361. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  2362. />
  2363. <div>
  2364. <span className="text-white">Read Status</span>
  2365. <p className="text-xs text-bambu-gray">View printer status and queue</p>
  2366. </div>
  2367. </label>
  2368. <label className="flex items-center gap-3 cursor-pointer">
  2369. <input
  2370. type="checkbox"
  2371. checked={newAPIKeyPermissions.can_queue}
  2372. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
  2373. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  2374. />
  2375. <div>
  2376. <span className="text-white">Manage Queue</span>
  2377. <p className="text-xs text-bambu-gray">Add and remove items from print queue</p>
  2378. </div>
  2379. </label>
  2380. <label className="flex items-center gap-3 cursor-pointer">
  2381. <input
  2382. type="checkbox"
  2383. checked={newAPIKeyPermissions.can_control_printer}
  2384. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
  2385. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  2386. />
  2387. <div>
  2388. <span className="text-white">Control Printer</span>
  2389. <p className="text-xs text-bambu-gray">Pause, resume, and stop prints</p>
  2390. </div>
  2391. </label>
  2392. </div>
  2393. </div>
  2394. <div className="flex items-center gap-2 pt-2">
  2395. <Button
  2396. onClick={() => createAPIKeyMutation.mutate({
  2397. name: newAPIKeyName || 'Unnamed Key',
  2398. ...newAPIKeyPermissions,
  2399. })}
  2400. disabled={createAPIKeyMutation.isPending}
  2401. >
  2402. {createAPIKeyMutation.isPending ? (
  2403. <Loader2 className="w-4 h-4 animate-spin" />
  2404. ) : (
  2405. <Plus className="w-4 h-4" />
  2406. )}
  2407. Create Key
  2408. </Button>
  2409. <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
  2410. Cancel
  2411. </Button>
  2412. </div>
  2413. </CardContent>
  2414. </Card>
  2415. )}
  2416. {/* Existing Keys List */}
  2417. {apiKeysLoading ? (
  2418. <div className="flex justify-center py-12">
  2419. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  2420. </div>
  2421. ) : apiKeys && apiKeys.length > 0 ? (
  2422. <div className="space-y-3">
  2423. {apiKeys.map((key) => (
  2424. <Card key={key.id}>
  2425. <CardContent className="py-3">
  2426. <div className="flex items-center justify-between">
  2427. <div className="flex items-center gap-3">
  2428. <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  2429. <div>
  2430. <p className="text-white font-medium">{key.name}</p>
  2431. <p className="text-xs text-bambu-gray">
  2432. {key.key_prefix}••••••••
  2433. {key.last_used && ` · Last used: ${formatDateOnly(key.last_used)}`}
  2434. </p>
  2435. </div>
  2436. </div>
  2437. <div className="flex items-center gap-2">
  2438. <div className="flex gap-1 text-xs">
  2439. {key.can_read_status && (
  2440. <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">Read</span>
  2441. )}
  2442. {key.can_queue && (
  2443. <span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded">Queue</span>
  2444. )}
  2445. {key.can_control_printer && (
  2446. <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">Control</span>
  2447. )}
  2448. </div>
  2449. <Button
  2450. variant="secondary"
  2451. size="sm"
  2452. onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
  2453. >
  2454. <Trash2 className="w-4 h-4 text-red-400" />
  2455. </Button>
  2456. </div>
  2457. </div>
  2458. </CardContent>
  2459. </Card>
  2460. ))}
  2461. </div>
  2462. ) : (
  2463. <Card>
  2464. <CardContent className="py-12">
  2465. <div className="text-center text-bambu-gray">
  2466. <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
  2467. <p className="text-lg font-medium text-white mb-2">No API keys</p>
  2468. <p className="text-sm mb-4">Create an API key to integrate with external services.</p>
  2469. <Button onClick={() => setShowCreateAPIKey(true)}>
  2470. <Plus className="w-4 h-4" />
  2471. Create Your First Key
  2472. </Button>
  2473. </div>
  2474. </CardContent>
  2475. </Card>
  2476. )}
  2477. {/* Webhook Documentation */}
  2478. <Card className="mt-6">
  2479. <CardHeader>
  2480. <h3 className="text-base font-semibold text-white">Webhook Endpoints</h3>
  2481. </CardHeader>
  2482. <CardContent className="space-y-3 text-sm">
  2483. <p className="text-bambu-gray">
  2484. Use your API key in the <code className="text-bambu-green">X-API-Key</code> header.
  2485. </p>
  2486. <div className="space-y-2 font-mono text-xs">
  2487. <div className="p-2 bg-bambu-dark rounded">
  2488. <span className="text-blue-400">GET</span>{' '}
  2489. <span className="text-white">/api/v1/webhook/status</span>
  2490. <span className="text-bambu-gray"> - Get all printer status</span>
  2491. </div>
  2492. <div className="p-2 bg-bambu-dark rounded">
  2493. <span className="text-blue-400">GET</span>{' '}
  2494. <span className="text-white">/api/v1/webhook/status/:id</span>
  2495. <span className="text-bambu-gray"> - Get specific printer status</span>
  2496. </div>
  2497. <div className="p-2 bg-bambu-dark rounded">
  2498. <span className="text-green-400">POST</span>{' '}
  2499. <span className="text-white">/api/v1/webhook/queue</span>
  2500. <span className="text-bambu-gray"> - Add to print queue</span>
  2501. </div>
  2502. <div className="p-2 bg-bambu-dark rounded">
  2503. <span className="text-orange-400">POST</span>{' '}
  2504. <span className="text-white">/api/v1/webhook/printer/:id/pause</span>
  2505. <span className="text-bambu-gray"> - Pause print</span>
  2506. </div>
  2507. <div className="p-2 bg-bambu-dark rounded">
  2508. <span className="text-orange-400">POST</span>{' '}
  2509. <span className="text-white">/api/v1/webhook/printer/:id/resume</span>
  2510. <span className="text-bambu-gray"> - Resume print</span>
  2511. </div>
  2512. <div className="p-2 bg-bambu-dark rounded">
  2513. <span className="text-red-400">POST</span>{' '}
  2514. <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
  2515. <span className="text-bambu-gray"> - Stop print</span>
  2516. </div>
  2517. </div>
  2518. </CardContent>
  2519. </Card>
  2520. </div>
  2521. {/* Right Column - API Browser */}
  2522. <div>
  2523. <div className="mb-6">
  2524. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2525. <Globe className="w-5 h-5 text-bambu-green" />
  2526. API Browser
  2527. </h2>
  2528. <p className="text-sm text-bambu-gray mt-1">
  2529. Explore and test all available API endpoints.
  2530. </p>
  2531. </div>
  2532. {/* API Key Input for Testing */}
  2533. <Card className="mb-4">
  2534. <CardContent className="py-3">
  2535. <label className="block text-sm text-bambu-gray mb-2">API Key for Testing</label>
  2536. <input
  2537. type="text"
  2538. value={testApiKey}
  2539. onChange={(e) => setTestApiKey(e.target.value)}
  2540. placeholder="Paste your API key here to test authenticated endpoints..."
  2541. 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"
  2542. />
  2543. <p className="text-xs text-bambu-gray mt-2">
  2544. This key will be sent as <code className="text-bambu-green">X-API-Key</code> header with requests.
  2545. </p>
  2546. </CardContent>
  2547. </Card>
  2548. <APIBrowser apiKey={testApiKey} />
  2549. </div>
  2550. </div>
  2551. )}
  2552. {/* Virtual Printer Tab */}
  2553. {activeTab === 'virtual-printer' && (
  2554. <VirtualPrinterSettings />
  2555. )}
  2556. {/* Filament Tab */}
  2557. {activeTab === 'filament' && localSettings && (
  2558. <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
  2559. {/* Left Column - AMS Display Thresholds */}
  2560. <div className="flex-1 lg:max-w-xl">
  2561. <Card>
  2562. <CardHeader>
  2563. <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
  2564. </CardHeader>
  2565. <CardContent className="space-y-4">
  2566. <p className="text-sm text-bambu-gray">
  2567. Configure color thresholds for AMS humidity and temperature indicators.
  2568. </p>
  2569. {/* Humidity Thresholds */}
  2570. <div className="space-y-3">
  2571. <div className="flex items-center gap-2 text-white">
  2572. <Droplets className="w-4 h-4 text-blue-400" />
  2573. <span className="font-medium">Humidity</span>
  2574. </div>
  2575. <div className="grid grid-cols-2 gap-3">
  2576. <div>
  2577. <label className="block text-sm text-bambu-gray mb-1">
  2578. Good (green) ≤
  2579. </label>
  2580. <div className="flex items-center gap-2">
  2581. <input
  2582. type="number"
  2583. min="0"
  2584. max="100"
  2585. value={localSettings.ams_humidity_good ?? 40}
  2586. onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
  2587. 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"
  2588. />
  2589. <span className="text-bambu-gray">%</span>
  2590. </div>
  2591. </div>
  2592. <div>
  2593. <label className="block text-sm text-bambu-gray mb-1">
  2594. Fair (orange) ≤
  2595. </label>
  2596. <div className="flex items-center gap-2">
  2597. <input
  2598. type="number"
  2599. min="0"
  2600. max="100"
  2601. value={localSettings.ams_humidity_fair ?? 60}
  2602. onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
  2603. 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"
  2604. />
  2605. <span className="text-bambu-gray">%</span>
  2606. </div>
  2607. </div>
  2608. </div>
  2609. <p className="text-xs text-bambu-gray">
  2610. Above fair threshold shows as red (bad)
  2611. </p>
  2612. </div>
  2613. {/* Temperature Thresholds */}
  2614. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  2615. <div className="flex items-center gap-2 text-white">
  2616. <Thermometer className="w-4 h-4 text-orange-400" />
  2617. <span className="font-medium">Temperature</span>
  2618. </div>
  2619. <div className="grid grid-cols-2 gap-3">
  2620. <div>
  2621. <label className="block text-sm text-bambu-gray mb-1">
  2622. Good (blue) ≤
  2623. </label>
  2624. <div className="flex items-center gap-2">
  2625. <input
  2626. type="number"
  2627. step="0.5"
  2628. min="0"
  2629. max="60"
  2630. value={localSettings.ams_temp_good ?? 28}
  2631. onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
  2632. 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"
  2633. />
  2634. <span className="text-bambu-gray">°C</span>
  2635. </div>
  2636. </div>
  2637. <div>
  2638. <label className="block text-sm text-bambu-gray mb-1">
  2639. Fair (orange) ≤
  2640. </label>
  2641. <div className="flex items-center gap-2">
  2642. <input
  2643. type="number"
  2644. step="0.5"
  2645. min="0"
  2646. max="60"
  2647. value={localSettings.ams_temp_fair ?? 35}
  2648. onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
  2649. 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"
  2650. />
  2651. <span className="text-bambu-gray">°C</span>
  2652. </div>
  2653. </div>
  2654. </div>
  2655. <p className="text-xs text-bambu-gray">
  2656. Above fair threshold shows as red (hot)
  2657. </p>
  2658. </div>
  2659. {/* History Retention */}
  2660. <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
  2661. <div className="flex items-center gap-2 text-white">
  2662. <Database className="w-4 h-4 text-purple-400" />
  2663. <span className="font-medium">History Retention</span>
  2664. </div>
  2665. <div>
  2666. <label className="block text-sm text-bambu-gray mb-1">
  2667. Keep sensor history for
  2668. </label>
  2669. <div className="flex items-center gap-2">
  2670. <input
  2671. type="number"
  2672. min="1"
  2673. max="365"
  2674. value={localSettings.ams_history_retention_days ?? 30}
  2675. onChange={(e) => updateSetting('ams_history_retention_days', parseInt(e.target.value) || 30)}
  2676. 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"
  2677. />
  2678. <span className="text-bambu-gray">days</span>
  2679. </div>
  2680. </div>
  2681. <p className="text-xs text-bambu-gray">
  2682. Older humidity and temperature data will be automatically deleted
  2683. </p>
  2684. </div>
  2685. {/* Per-Printer Mapping Default */}
  2686. <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
  2687. <div className="flex items-center gap-2 text-white">
  2688. <Printer className="w-4 h-4 text-bambu-green" />
  2689. <span className="font-medium">Print Modal</span>
  2690. </div>
  2691. <div className="flex items-center justify-between">
  2692. <div>
  2693. <label className="block text-sm text-white">
  2694. Expand custom mapping by default
  2695. </label>
  2696. <p className="text-xs text-bambu-gray mt-0.5">
  2697. When printing to multiple printers, show per-printer AMS mapping expanded
  2698. </p>
  2699. </div>
  2700. <label className="relative inline-flex items-center cursor-pointer">
  2701. <input
  2702. type="checkbox"
  2703. checked={localSettings.per_printer_mapping_expanded ?? false}
  2704. onChange={(e) => updateSetting('per_printer_mapping_expanded', e.target.checked)}
  2705. className="sr-only peer"
  2706. />
  2707. <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>
  2708. </label>
  2709. </div>
  2710. </div>
  2711. </CardContent>
  2712. </Card>
  2713. </div>
  2714. {/* Right Column - Spoolman Integration */}
  2715. <div className="flex-1 lg:max-w-xl">
  2716. <SpoolmanSettings />
  2717. </div>
  2718. </div>
  2719. )}
  2720. {/* Delete API Key Confirmation */}
  2721. {showDeleteAPIKeyConfirm !== null && (
  2722. <ConfirmModal
  2723. title="Delete API Key"
  2724. message="Are you sure you want to delete this API key? Any integrations using this key will stop working."
  2725. confirmText="Delete Key"
  2726. variant="danger"
  2727. onConfirm={() => {
  2728. deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);
  2729. setShowDeleteAPIKeyConfirm(null);
  2730. }}
  2731. onCancel={() => setShowDeleteAPIKeyConfirm(null)}
  2732. />
  2733. )}
  2734. {/* Smart Plug Modal */}
  2735. {showPlugModal && (
  2736. <AddSmartPlugModal
  2737. plug={editingPlug}
  2738. onClose={() => {
  2739. setShowPlugModal(false);
  2740. setEditingPlug(null);
  2741. }}
  2742. />
  2743. )}
  2744. {/* Notification Modal */}
  2745. {showNotificationModal && (
  2746. <AddNotificationModal
  2747. provider={editingProvider}
  2748. onClose={() => {
  2749. setShowNotificationModal(false);
  2750. setEditingProvider(null);
  2751. }}
  2752. />
  2753. )}
  2754. {/* Template Editor Modal */}
  2755. {editingTemplate && (
  2756. <NotificationTemplateEditor
  2757. template={editingTemplate}
  2758. onClose={() => setEditingTemplate(null)}
  2759. />
  2760. )}
  2761. {/* Notification Log Viewer */}
  2762. {showLogViewer && (
  2763. <NotificationLogViewer
  2764. onClose={() => setShowLogViewer(false)}
  2765. />
  2766. )}
  2767. {/* Confirm Modal: Clear Notification Logs */}
  2768. {showClearLogsConfirm && (
  2769. <ConfirmModal
  2770. title="Clear Notification Logs"
  2771. message="This will permanently delete all notification logs older than 30 days. This action cannot be undone."
  2772. confirmText="Clear Logs"
  2773. variant="warning"
  2774. onConfirm={async () => {
  2775. setShowClearLogsConfirm(false);
  2776. try {
  2777. const result = await api.clearNotificationLogs(30);
  2778. showToast(result.message, 'success');
  2779. } catch {
  2780. showToast('Failed to clear logs', 'error');
  2781. }
  2782. }}
  2783. onCancel={() => setShowClearLogsConfirm(false)}
  2784. />
  2785. )}
  2786. {/* Confirm Modal: Clear Local Storage */}
  2787. {showClearStorageConfirm && (
  2788. <ConfirmModal
  2789. title="Reset UI Preferences"
  2790. 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."
  2791. confirmText="Reset Preferences"
  2792. variant="default"
  2793. onConfirm={() => {
  2794. setShowClearStorageConfirm(false);
  2795. localStorage.clear();
  2796. showToast('UI preferences reset. Refreshing...', 'success');
  2797. setTimeout(() => window.location.reload(), 1000);
  2798. }}
  2799. onCancel={() => setShowClearStorageConfirm(false)}
  2800. />
  2801. )}
  2802. {/* Confirm Modal: Bulk Plug Action */}
  2803. {showBulkPlugConfirm && (
  2804. <ConfirmModal
  2805. title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  2806. 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!' : ''}`}
  2807. confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  2808. variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}
  2809. onConfirm={() => {
  2810. const action = showBulkPlugConfirm;
  2811. setShowBulkPlugConfirm(null);
  2812. bulkPlugActionMutation.mutate(action);
  2813. }}
  2814. onCancel={() => setShowBulkPlugConfirm(null)}
  2815. />
  2816. )}
  2817. {/* Release Notes Modal */}
  2818. {showReleaseNotes && updateCheck?.release_notes && (
  2819. <div
  2820. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  2821. onClick={() => setShowReleaseNotes(false)}
  2822. >
  2823. <Card className="w-full max-w-2xl max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  2824. <CardHeader className="flex flex-row items-center justify-between shrink-0">
  2825. <div>
  2826. <h2 className="text-lg font-semibold text-white">
  2827. Release Notes - v{updateCheck.latest_version}
  2828. </h2>
  2829. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  2830. <p className="text-sm text-bambu-gray">{updateCheck.release_name}</p>
  2831. )}
  2832. </div>
  2833. <button
  2834. onClick={() => setShowReleaseNotes(false)}
  2835. className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
  2836. >
  2837. <X className="w-5 h-5" />
  2838. </button>
  2839. </CardHeader>
  2840. <CardContent className="overflow-y-auto flex-1">
  2841. <pre className="text-sm text-bambu-gray whitespace-pre-wrap font-sans">
  2842. {updateCheck.release_notes}
  2843. </pre>
  2844. </CardContent>
  2845. <div className="p-4 border-t border-bambu-dark-tertiary shrink-0 flex gap-2">
  2846. {updateCheck.release_url && (
  2847. <a
  2848. href={updateCheck.release_url}
  2849. target="_blank"
  2850. rel="noopener noreferrer"
  2851. className="flex-1"
  2852. >
  2853. <Button variant="secondary" className="w-full">
  2854. <ExternalLink className="w-4 h-4" />
  2855. View on GitHub
  2856. </Button>
  2857. </a>
  2858. )}
  2859. <Button
  2860. onClick={() => setShowReleaseNotes(false)}
  2861. className="flex-1"
  2862. >
  2863. Close
  2864. </Button>
  2865. </div>
  2866. </Card>
  2867. </div>
  2868. )}
  2869. {/* Users Tab */}
  2870. {activeTab === 'users' && (
  2871. <div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
  2872. <div>
  2873. <div className="mb-6">
  2874. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2875. <Users className="w-5 h-5 text-bambu-green" />
  2876. User Authentication
  2877. </h2>
  2878. <p className="text-sm text-bambu-gray mt-1">
  2879. Enable authentication to secure your Bambuddy instance and manage user access.
  2880. </p>
  2881. </div>
  2882. <Card>
  2883. <CardContent className="py-6">
  2884. {!authEnabled ? (
  2885. <div className="space-y-4">
  2886. <div className="flex items-center gap-3">
  2887. <div className={`w-12 h-12 rounded-full flex items-center justify-center ${authEnabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
  2888. {authEnabled ? (
  2889. <Lock className="w-6 h-6 text-green-400" />
  2890. ) : (
  2891. <Unlock className="w-6 h-6 text-gray-400" />
  2892. )}
  2893. </div>
  2894. <div className="flex-1">
  2895. <h3 className="text-white font-medium">Authentication Disabled</h3>
  2896. <p className="text-sm text-bambu-gray">
  2897. Your Bambuddy instance is currently accessible without authentication.
  2898. </p>
  2899. </div>
  2900. </div>
  2901. <div className="pt-4 border-t border-bambu-dark-tertiary">
  2902. <p className="text-sm text-bambu-gray mb-4">
  2903. Enable authentication to:
  2904. </p>
  2905. <ul className="space-y-2 text-sm text-bambu-gray mb-4">
  2906. <li className="flex items-start gap-2">
  2907. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  2908. <span>Require login to access the system</span>
  2909. </li>
  2910. <li className="flex items-start gap-2">
  2911. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  2912. <span>Manage multiple users with different roles</span>
  2913. </li>
  2914. <li className="flex items-start gap-2">
  2915. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  2916. <span>Control access to printer settings and user management</span>
  2917. </li>
  2918. </ul>
  2919. <Button
  2920. type="button"
  2921. onClick={(e) => {
  2922. e.preventDefault();
  2923. navigate('/setup');
  2924. }}
  2925. className="w-full"
  2926. >
  2927. <Lock className="w-4 h-4" />
  2928. Activate Authentication
  2929. </Button>
  2930. </div>
  2931. </div>
  2932. ) : (
  2933. <div className="space-y-4">
  2934. <div className="flex items-center gap-3">
  2935. <div className="w-12 h-12 rounded-full flex items-center justify-center bg-green-500/20">
  2936. <Lock className="w-6 h-6 text-green-400" />
  2937. </div>
  2938. <div className="flex-1">
  2939. <h3 className="text-white font-medium">Authentication Enabled</h3>
  2940. <p className="text-sm text-bambu-gray">
  2941. Your Bambuddy instance is secured with authentication.
  2942. </p>
  2943. </div>
  2944. </div>
  2945. {user && (
  2946. <div className="pt-4 border-t border-bambu-dark-tertiary">
  2947. <div className="flex items-center justify-between mb-4">
  2948. <div>
  2949. <p className="text-sm text-bambu-gray">Current User</p>
  2950. <p className="text-white font-medium">{user.username}</p>
  2951. <p className="text-xs text-bambu-gray mt-1">
  2952. Role: <span className="capitalize">{user.role}</span>
  2953. </p>
  2954. </div>
  2955. <div className={`px-3 py-1 rounded-full text-xs font-medium ${
  2956. user.role === 'admin'
  2957. ? 'bg-purple-500/20 text-purple-300'
  2958. : 'bg-blue-500/20 text-blue-300'
  2959. }`}>
  2960. {user.role === 'admin' ? 'Admin' : 'User'}
  2961. </div>
  2962. </div>
  2963. </div>
  2964. )}
  2965. <div className="pt-4 border-t border-bambu-dark-tertiary space-y-3">
  2966. <Button
  2967. onClick={() => navigate('/users')}
  2968. className="w-full"
  2969. variant="secondary"
  2970. >
  2971. <Users className="w-4 h-4" />
  2972. Manage Users
  2973. </Button>
  2974. {user?.role === 'admin' && (
  2975. <Button
  2976. onClick={() => setShowDisableAuthConfirm(true)}
  2977. className="w-full"
  2978. variant="secondary"
  2979. >
  2980. <Unlock className="w-4 h-4" />
  2981. Disable Authentication
  2982. </Button>
  2983. )}
  2984. </div>
  2985. </div>
  2986. )}
  2987. </CardContent>
  2988. </Card>
  2989. </div>
  2990. {authEnabled && (
  2991. <div>
  2992. <div className="mb-6">
  2993. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2994. <Shield className="w-5 h-5 text-bambu-green" />
  2995. Role Permissions
  2996. </h2>
  2997. <p className="text-sm text-bambu-gray mt-1">
  2998. Overview of what each role can do.
  2999. </p>
  3000. </div>
  3001. <div className="space-y-4">
  3002. <Card>
  3003. <CardHeader>
  3004. <div className="flex items-center gap-2">
  3005. <div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
  3006. <Shield className="w-4 h-4 text-purple-300" />
  3007. </div>
  3008. <h3 className="text-white font-medium">Admin</h3>
  3009. </div>
  3010. </CardHeader>
  3011. <CardContent>
  3012. <ul className="space-y-2 text-sm text-bambu-gray">
  3013. <li className="flex items-start gap-2">
  3014. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  3015. <span>Manage printer settings</span>
  3016. </li>
  3017. <li className="flex items-start gap-2">
  3018. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  3019. <span>Create, edit, and delete users</span>
  3020. </li>
  3021. <li className="flex items-start gap-2">
  3022. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  3023. <span>Access all system features</span>
  3024. </li>
  3025. </ul>
  3026. </CardContent>
  3027. </Card>
  3028. <Card>
  3029. <CardHeader>
  3030. <div className="flex items-center gap-2">
  3031. <div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center">
  3032. <Users className="w-4 h-4 text-blue-300" />
  3033. </div>
  3034. <h3 className="text-white font-medium">User</h3>
  3035. </div>
  3036. </CardHeader>
  3037. <CardContent>
  3038. <ul className="space-y-2 text-sm text-bambu-gray">
  3039. <li className="flex items-start gap-2">
  3040. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  3041. <span>Send print jobs</span>
  3042. </li>
  3043. <li className="flex items-start gap-2">
  3044. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  3045. <span>Manage files and archives</span>
  3046. </li>
  3047. <li className="flex items-start gap-2">
  3048. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  3049. <span>Manage filament</span>
  3050. </li>
  3051. </ul>
  3052. </CardContent>
  3053. </Card>
  3054. </div>
  3055. </div>
  3056. )}
  3057. </div>
  3058. )}
  3059. {/* Backup Tab */}
  3060. {activeTab === 'backup' && (
  3061. <GitHubBackupSettings />
  3062. )}
  3063. {/* Disable Authentication Confirmation Modal */}
  3064. {showDisableAuthConfirm && (
  3065. <ConfirmModal
  3066. title="Disable Authentication"
  3067. message="Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled."
  3068. confirmText="Disable Authentication"
  3069. variant="danger"
  3070. onConfirm={async () => {
  3071. try {
  3072. await api.disableAuth();
  3073. showToast('Authentication disabled successfully', 'success');
  3074. await refreshAuth();
  3075. setShowDisableAuthConfirm(false);
  3076. // Refresh the page to ensure all protected routes are accessible
  3077. window.location.href = '/';
  3078. } catch (error: unknown) {
  3079. const message = error instanceof Error ? error.message : 'Failed to disable authentication';
  3080. showToast(message, 'error');
  3081. }
  3082. }}
  3083. onCancel={() => setShowDisableAuthConfirm(false)}
  3084. />
  3085. )}
  3086. </div>
  3087. );
  3088. }