SettingsPage.tsx 428 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079
  1. import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
  2. import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Pencil, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay, Info } 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 { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
  9. import { checkPasswordComplexity } from '../utils/password';
  10. import { fleetAudience, sponsorHref } from '../utils/fleetAudience';
  11. import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
  12. import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode';
  13. import { inventoryLocationsQueryKey } from '../utils/inventoryQueries';
  14. import {
  15. locationSensorReadingAlertStatus,
  16. locationSensorValueColorClass,
  17. useLocationSensorColorPrefs,
  18. } from '../utils/locationSensorDefaults';
  19. import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
  20. import { SECRET_PLACEHOLDER, secretsToReenter, selectPlaceholderOnFocus } from '../utils/secrets';
  21. import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
  22. import type { Printer as PrinterInfo, APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationHASensor, LocationHASensorReading, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
  23. import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
  24. import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
  25. import { CameraTokensSection } from './CameraTokensPage';
  26. import { ConnectedAppsSection } from '../components/ConnectedAppsSection';
  27. import { StreamOverlayBuilder } from '../components/StreamOverlayBuilder';
  28. import { Collapsible } from '../components/Collapsible';
  29. import { CopyButton } from '../components/CopyButton';
  30. import { Button } from '../components/Button';
  31. import { SmartPlugCard } from '../components/SmartPlugCard';
  32. import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
  33. import { HASensorModal } from '../components/HASensorModal';
  34. import { LocationHASensorModal } from '../components/LocationHASensorModal';
  35. import { LocationSensorOptionsModal } from '../components/LocationSensorOptionsModal';
  36. import { NotificationProviderCard } from '../components/NotificationProviderCard';
  37. import { AddNotificationModal } from '../components/AddNotificationModal';
  38. import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
  39. import { NotificationLogViewer } from '../components/NotificationLogViewer';
  40. import { ConfirmModal } from '../components/ConfirmModal';
  41. import { ApiKeyQRCodeModal } from '../components/ApiKeyQRCodeModal';
  42. import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
  43. import { LdapUserPicker } from '../components/LdapUserPicker';
  44. import { SpoolmanSettings } from '../components/SpoolmanSettings';
  45. import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
  46. import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
  47. import { ProfileDefaultsSettings } from '../components/ProfileDefaultsSettings';
  48. import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
  49. import { VirtualPrinterList } from '../components/VirtualPrinterList';
  50. import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
  51. import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
  52. import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
  53. import { EmailSettings } from '../components/EmailSettings';
  54. import { LDAPSettings } from '../components/LDAPSettings';
  55. import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
  56. import { TwoFactorSettings } from '../components/TwoFactorSettings';
  57. import { AdminDisableTwoFactor } from '../components/AdminDisableTwoFactor';
  58. import { cameraTestSuccessMessage } from '../utils/cameraTestMessage';
  59. import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
  60. import { SecurityStatusCard } from '../components/SecurityStatusCard';
  61. import { APIBrowser } from '../components/APIBrowser';
  62. import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
  63. import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
  64. import { availableLanguages } from '../i18n';
  65. import { useToast } from '../contexts/ToastContext';
  66. import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
  67. import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react';
  68. import { Gauge, Link2, Palette, type LucideIcon } from 'lucide-react';
  69. import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
  70. import type { UsersSubTab } from '../lib/settingsSearch';
  71. import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
  72. import { NumberInput } from '../components/NumberInput';
  73. import { PrinterSearchPicker } from '../components/PrinterSearchPicker';
  74. import { UsbCameraDevicePicker } from '../components/UsbCameraDevicePicker';
  75. const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
  76. type TabType = typeof validTabs[number];
  77. interface SettingsTabEntry { tab: TabType; icon: LucideIcon; label: string; extra?: ReactNode }
  78. // Cross-tab search registrations for cards rendered inline in this file.
  79. // Adding a new settings card? Register it here (or, if the card lives in its
  80. // own component file, call registerSettingsSearch at that file's module scope).
  81. registerSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' });
  82. registerSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' });
  83. registerSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' });
  84. registerSettingsSearch({ labelKey: 'settings.camera', tab: 'camera', keywords: 'camera external video stream rtsp mjpeg usb snapshot rotation chamber light led lamp', anchor: 'card-camera' });
  85. registerSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' });
  86. registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' });
  87. registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });
  88. registerSettingsSearch({ labelKey: 'settings.dataManagement', tab: 'general', keywords: 'data reset clear logs notifications preferences', anchor: 'card-data' });
  89. registerSettingsSearch({ labelKey: 'settings.smartPlugs', tab: 'plugs', keywords: 'smart plug energy power automation tapo kasa tplink shelly', anchor: 'card-plugs' });
  90. registerSettingsSearch({ labelKey: 'haSensors.sectionTitle', tab: 'sensors', keywords: 'home assistant sensor printer temperature humidity alert notify block print', anchor: 'card-ha-sensors' });
  91. registerSettingsSearch({ labelKey: 'locationHaSensors.sectionTitle', tab: 'sensors', keywords: 'home assistant sensor location storage box temperature humidity battery alert notify', anchor: 'card-location-sensors' });
  92. registerSettingsSearch({ labelKey: 'settings.providers', tab: 'notifications', keywords: 'telegram discord email notification providers webhook', anchor: 'card-providers' });
  93. registerSettingsSearch({ labelKey: 'settings.messageTemplates', tab: 'notifications', keywords: 'message templates notification text edit', anchor: 'card-templates' });
  94. registerSettingsSearch({ labelKey: 'settings.defaultPrintOptions', labelFallback: 'Default Print Options', tab: 'queue', keywords: 'print bed leveling flow calibration vibration first layer timelapse', anchor: 'card-print-options' });
  95. registerSettingsSearch({ labelKey: 'settings.tempFanPresetsTitle', labelFallback: 'Temperature & Fan Presets', tab: 'queue', keywords: 'temperature fan presets nozzle bed chamber quick buttons popover', anchor: 'card-temp-fan-presets' });
  96. registerSettingsSearch({ labelKey: 'settings.staggeredStart', labelFallback: 'Staggered Start', tab: 'queue', keywords: 'staggered batch delay start queue group', anchor: 'card-staggered' });
  97. registerSettingsSearch({ labelKey: 'settings.plateClear', labelFallback: 'Plate-Clear Confirmation', tab: 'queue', keywords: 'plate clear confirm auto queue', anchor: 'card-plate' });
  98. registerSettingsSearch({ labelKey: 'settings.concurrentUploadsTitle', labelFallback: 'Concurrent Uploads', tab: 'queue', keywords: 'concurrent parallel upload transfer ftp queue slow farm simultaneous', anchor: 'card-concurrent-uploads' });
  99. registerSettingsSearch({ labelKey: 'settings.gcodeInjection', labelFallback: 'G-code Injection', tab: 'queue', keywords: 'gcode injection start end autoprint farmloop swapmod autoclear printflow', anchor: 'card-gcode' });
  100. registerSettingsSearch({ labelKey: 'settings.slicerCard', labelFallback: 'Slicer', tab: 'queue', keywords: 'slicer orcaslicer bambustudio orca bambu api sidecar url docker preferred', anchor: 'card-slicer' });
  101. registerSettingsSearch({ labelKey: 'settings.queueDrying', tab: 'queue', keywords: 'drying presets temperature time humidity ams', anchor: 'card-drying' });
  102. registerSettingsSearch({ labelKey: 'settings.filamentChecks', tab: 'filament', keywords: 'filament check warning runout remaining', anchor: 'card-filamentchecks' });
  103. registerSettingsSearch({ labelKey: 'settings.printModal', tab: 'filament', keywords: 'print modal custom mapping', anchor: 'card-printmodal' });
  104. registerSettingsSearch({ labelKey: 'settings.amsDisplayThresholds', tab: 'filament', keywords: 'ams humidity temperature threshold history retention', anchor: 'card-amsthresholds' });
  105. registerSettingsSearch({ labelKey: 'inventory.profileDefaults.title', tab: 'filament', keywords: 'profile default k-profile k factor pressure advance slicer preset new spool', anchor: 'card-profiledefaults' });
  106. registerSettingsSearch({ labelKey: 'settings.externalUrl', tab: 'network', keywords: 'external url reverse proxy public notification link', anchor: 'card-externalurl' });
  107. registerSettingsSearch({ labelKey: 'settings.ftpRetry', tab: 'network', keywords: 'ftp retry upload retries backoff', anchor: 'card-ftpretry' });
  108. registerSettingsSearch({ labelKey: 'settings.homeAssistant', tab: 'network', keywords: 'home assistant ha hass mqtt integration', anchor: 'card-ha' });
  109. registerSettingsSearch({ labelKey: 'settings.mqttPublishing', tab: 'network', keywords: 'mqtt publish broker topic', anchor: 'card-mqtt' });
  110. registerSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network', keywords: 'prometheus metrics grafana monitoring bearer token', anchor: 'card-prometheus' });
  111. registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' });
  112. registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });
  113. registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });
  114. registerSettingsSearch({ labelKey: 'connectedApps.title', tab: 'apikeys', keywords: 'connected app sign in single sign-on sso oauth login orders', anchor: 'card-connected-apps' });
  115. registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'camera', keywords: 'camera token long-lived home assistant frigate kiosk stream cam wall', anchor: 'card-camera-tokens' });
  116. registerSettingsSearch({ labelKey: 'streamOverlay.builder.title', labelFallback: 'Streaming Overlay', tab: 'camera', keywords: 'streaming overlay obs browser source stream url token', anchor: 'card-stream-overlay' });
  117. registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });
  118. registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });
  119. registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
  120. registerSettingsSearch({ labelKey: 'settings.users', tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' });
  121. registerSettingsSearch({ labelKey: 'settings.groups', tab: 'users', subTab: 'users', keywords: 'groups roles permissions administrators operators viewers', anchor: 'card-groups' });
  122. registerSettingsSearch({ labelKey: 'printerAccess.tab', tab: 'users', subTab: 'printer-access', keywords: 'printer access groups teams locations limit restrict scope who can see', anchor: 'card-printer-access' });
  123. registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback: 'Session Policy', tab: 'users', subTab: 'users', keywords: 'session timeout expiry logout remember me jwt token lifetime', anchor: 'card-session-policy' });
  124. registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' });
  125. registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' });
  126. registerSettingsSearch({ labelKey: 'settings.tabs.backup', tab: 'backup', keywords: 'backup github restore download cloud sync profiles archives', anchor: 'card-backup' });
  127. // Sidebar (system pages and external links settings is rendered in the General tab)
  128. registerSettingsSearch({ labelKey: 'externalLinks.sidebarLayout', labelFallback: 'Sidebar', tab: 'general', keywords: 'sidebar layout links pages hide show external custom navigation url add', anchor: 'card-sidebar-links' });
  129. // Filament tab — integrations
  130. registerSettingsSearch({ labelKey: 'settings.filamentTracking', tab: 'filament', keywords: 'spoolman filament tracking inventory sync remote integration', anchor: 'card-spoolman' });
  131. registerSettingsSearch({ labelKey: 'settings.catalog.spoolCatalog', labelFallback: 'Spool Catalog', tab: 'filament', keywords: 'spool catalog entries brand material reset import export', anchor: 'card-spool-catalog' });
  132. registerSettingsSearch({ labelKey: 'settings.colorCatalog.title', labelFallback: 'Color Catalog', tab: 'filament', keywords: 'color catalog hex swatch palette sync reset', anchor: 'card-color-catalog' });
  133. // Failure detection sub-cards
  134. registerSettingsSearch({ labelKey: 'settings.tabs.failureDetection', labelFallback: 'Failure Detection', tab: 'failure-detection', keywords: 'failure detection ai ml obico spaghetti detect monitoring', anchor: 'card-fd-ml' });
  135. registerSettingsSearch({ labelKey: 'failureDetection.perPrinterTitle', labelFallback: 'Per-Printer Settings', tab: 'failure-detection', keywords: 'failure detection per printer enable per-printer sensitivity', anchor: 'card-fd-perprinter' });
  136. registerSettingsSearch({ labelKey: 'failureDetection.statusTitle', labelFallback: 'Detection Status', tab: 'failure-detection', keywords: 'failure detection status running connection', anchor: 'card-fd-status' });
  137. registerSettingsSearch({ labelKey: 'failureDetection.historyTitle', labelFallback: 'Detection History', tab: 'failure-detection', keywords: 'failure detection history log events', anchor: 'card-fd-history' });
  138. // Email auth sub-cards (subTab=email)
  139. registerSettingsSearch({ labelKey: 'settings.email.advancedAuth', labelFallback: 'Advanced Email Authentication', tab: 'users', subTab: 'email', keywords: 'email authentication advanced password reset self-service forgot', anchor: 'card-email-advanced-auth' });
  140. registerSettingsSearch({ labelKey: 'settings.email.testConnection', labelFallback: 'Test SMTP Connection', tab: 'users', subTab: 'email', keywords: 'email smtp test connection send check', anchor: 'card-email-test' });
  141. // Two-Factor sub-cards (subTab=twofa)
  142. registerSettingsSearch({ labelKey: 'settings.twoFa.totpTitle', labelFallback: 'Authenticator App (TOTP)', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa totp authenticator app google authy otp', anchor: 'card-2fa-totp' });
  143. registerSettingsSearch({ labelKey: 'settings.twoFa.emailOtpTitle', labelFallback: 'Email One-Time Codes', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa email otp one time code', anchor: 'card-2fa-emailotp' });
  144. registerSettingsSearch({ labelKey: 'settings.twoFa.linkedAccounts', labelFallback: 'Linked Accounts', tab: 'users', subTab: 'twofa', keywords: 'two factor 2fa linked accounts sso oidc provider google github', anchor: 'card-2fa-linked' });
  145. // OIDC / SSO (subTab=oidc)
  146. registerSettingsSearch({ labelKey: 'settings.oidc.title', labelFallback: 'Single Sign-On (OIDC)', tab: 'users', subTab: 'oidc', keywords: 'sso oidc openid single sign-on pocketid authentik keycloak google okta azure provider', anchor: 'card-oidc' });
  147. // LDAP server config card (complements existing card-ldap)
  148. registerSettingsSearch({ labelKey: 'settings.ldap.serverConfig', labelFallback: 'LDAP Server Configuration', tab: 'users', subTab: 'ldap', keywords: 'ldap server url bind dn user search base group filter tls', anchor: 'card-ldap-server' });
  149. // Backup sub-cards
  150. registerSettingsSearch({ labelKey: 'backup.githubBackup', labelFallback: 'GitHub Backup', tab: 'backup', keywords: 'github backup cloud remote sync profiles token', anchor: 'card-backup-github' });
  151. registerSettingsSearch({ labelKey: 'backup.history', labelFallback: 'Backup History', tab: 'backup', keywords: 'backup history log runs github commits', anchor: 'card-backup-history' });
  152. registerSettingsSearch({ labelKey: 'backup.localBackup', labelFallback: 'Local Backup', tab: 'backup', keywords: 'local backup download zip manual export', anchor: 'card-backup-local' });
  153. registerSettingsSearch({ labelKey: 'backup.scheduledBackup', labelFallback: 'Scheduled Backups', tab: 'backup', keywords: 'scheduled backup automatic hourly daily weekly retention local path', anchor: 'card-backup-scheduled' });
  154. // Lowest humidity an AMS reports while its own dryer is running, measured on an
  155. // H2D/AMS 2 Pro that read 10-13% cold and 15-20% throughout every cycle (#2770).
  156. // A drying threshold below this can never be reached while the box is warm, so
  157. // auto-drying would end one cycle and immediately arm another.
  158. const HUMIDITY_DRYING_FLOOR = 20;
  159. const STORAGE_CATEGORY_COLORS: Record<string, string> = {
  160. database: 'bg-blue-600',
  161. library_files: 'bg-green-500',
  162. library_thumbnails: 'bg-teal-500',
  163. library_other: 'bg-emerald-700',
  164. archive_timelapses: 'bg-red-500',
  165. archive_thumbnails: 'bg-amber-500',
  166. archive_files: 'bg-sky-500',
  167. virtual_printer_uploads: 'bg-purple-500',
  168. virtual_printer_upload_cache: 'bg-fuchsia-500',
  169. virtual_printer_certs: 'bg-violet-500',
  170. virtual_printer_other: 'bg-purple-700',
  171. downloads: 'bg-cyan-500',
  172. plate_calibration: 'bg-lime-500',
  173. logs: 'bg-orange-500',
  174. other_data: 'bg-yellow-500',
  175. };
  176. const STORAGE_FALLBACK_COLORS = [
  177. 'bg-blue-500',
  178. 'bg-green-500',
  179. 'bg-yellow-500',
  180. 'bg-red-500',
  181. 'bg-orange-500',
  182. 'bg-teal-500',
  183. 'bg-cyan-500',
  184. 'bg-purple-500',
  185. ];
  186. const getStorageColor = (key: string, index: number) =>
  187. STORAGE_CATEGORY_COLORS[key] || STORAGE_FALLBACK_COLORS[index % STORAGE_FALLBACK_COLORS.length];
  188. const LOCATION_SENSOR_CATEGORY_ORDER: Record<string, number> = {
  189. temperature: 0,
  190. humidity: 1,
  191. battery: 2,
  192. };
  193. // Reads the live readings endpoint (reachable-aware) rather than the sensor
  194. // row's last_state, so a sensor Home Assistant has stopped reporting shows
  195. // "Unavailable" instead of silently keeping its last value on screen forever.
  196. function describeLocationSensorValue(
  197. reading: LocationHASensorReading | undefined,
  198. t: (key: string, opts?: Record<string, unknown>) => string
  199. ): string | null {
  200. if (!reading) return null;
  201. return describeHASensorReading(reading, t, { decimals: 2 });
  202. }
  203. // Chamber light for the camera (#1655), in the order the choice is shown.
  204. const CAMERA_LIGHT_MODES = ['off', 'all', 'selected'] as const;
  205. /** Closed-row hint for an external camera: its type and where it points. */
  206. function externalCameraSummary(printer: PrinterInfo): string {
  207. const type = (printer.external_camera_type || 'mjpeg').toUpperCase();
  208. const url = printer.external_camera_url || '';
  209. if (!url) return type;
  210. let where = url;
  211. try {
  212. where = new URL(url).host || url;
  213. } catch {
  214. // A USB device path or an unfinished URL: show it as typed.
  215. }
  216. return `${type} · ${where}`;
  217. }
  218. // The settings that decide where each saved secret is sent. A Home Assistant
  219. // token from the HA_TOKEN environment variable is the deployment's own and has
  220. // none: the URL may change without it.
  221. function settingsSecretDestinations(settings: { ha_token_from_env?: boolean }): Record<string, readonly string[]> {
  222. return {
  223. mqtt_password: ['mqtt_broker', 'mqtt_port', 'mqtt_use_tls'],
  224. ...(settings.ha_token_from_env ? {} : { ha_token: ['ha_url'] }),
  225. };
  226. }
  227. /** Under a secret input showing the placeholder: kept, or to be entered again. */
  228. function SecretHint({ name, value, reenter }: { name: string; value: unknown; reenter: string[] }) {
  229. const { t } = useTranslation();
  230. if (value !== SECRET_PLACEHOLDER) return null;
  231. const again = reenter.includes(name);
  232. return (
  233. <p className={`text-xs mt-1 ${again ? 'text-yellow-400' : 'text-bambu-gray'}`}>
  234. {t(again ? 'common.secretReenter' : 'common.secretKept')}
  235. </p>
  236. );
  237. }
  238. /**
  239. * The pause heads-up lead time as the server takes it: whole minutes, 1-120,
  240. * the default for an emptied field (#2984). Compared clamped too, so a value
  241. * the server stored clamped does not read as an unsaved change forever.
  242. */
  243. function clampPauseWarningMinutes(minutes: number | undefined): number {
  244. return Math.min(120, Math.max(1, Math.round(minutes || 5)));
  245. }
  246. export function SettingsPage() {
  247. const [cameraTokenRevision, setCameraTokenRevision] = useState(0);
  248. const queryClient = useQueryClient();
  249. const navigate = useNavigate();
  250. const [searchParams, setSearchParams] = useSearchParams();
  251. const { t, i18n } = useTranslation();
  252. const { showToast } = useToast();
  253. const { authEnabled, user, isAdmin, refreshAuth, hasPermission, loading: authLoading } = useAuth();
  254. const {
  255. mode, resolvedMode,
  256. darkStyle, darkBackground, darkAccent,
  257. lightStyle, lightBackground, lightAccent,
  258. setMode,
  259. setDarkStyle, setDarkBackground, setDarkAccent,
  260. setLightStyle, setLightBackground, setLightAccent,
  261. progressInTitle, setProgressInTitle, tabDisplay, setTabDisplay,
  262. } = useTheme();
  263. const [localSettings, setLocalSettings] = useState<AppSettings | null>(null);
  264. // Transient typed strings for the per-filament humidity threshold inputs
  265. // (#1605). Committed back to localSettings.ams_humidity_thresholds on blur
  266. // so intermediate values ("", "3", "5") are not eaten by the [5, 95] clamp
  267. // while the user is mid-typing.
  268. const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
  269. // Same transient-draft treatment for the ambient-drying sustained-minutes
  270. // input: committed (clamped 5..240) on blur, so clearing the field or typing
  271. // an intermediate value ("", "2" on the way to "25") is not rewritten by the
  272. // clamp mid-keystroke. Empty/non-numeric on blur reverts to the saved value.
  273. const [sustainedDraft, setSustainedDraft] = useState<string | null>(null);
  274. const [showPlugModal, setShowPlugModal] = useState(false);
  275. const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
  276. const [showHASensorModal, setShowHASensorModal] = useState(false);
  277. const [editingHASensor, setEditingHASensor] = useState<PrinterHASensor | null>(null);
  278. const [showLocationHASensorModal, setShowLocationHASensorModal] = useState(false);
  279. const [editingLocationHASensor, setEditingLocationHASensor] = useState<LocationHASensor | null>(null);
  280. const [showLocationSensorOptionsModal, setShowLocationSensorOptionsModal] = useState(false);
  281. const {
  282. colorize: colorizeLocationSensorValues,
  283. aboveColor: locationSensorAboveColor,
  284. belowColor: locationSensorBelowColor,
  285. optimalColor: locationSensorOptimalColor,
  286. } = useLocationSensorColorPrefs();
  287. const [deleteLocationSensorsTarget, setDeleteLocationSensorsTarget] = useState<{
  288. locationName: string;
  289. sensorIds: number[];
  290. } | null>(null);
  291. const [showNotificationModal, setShowNotificationModal] = useState(false);
  292. const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
  293. const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
  294. const [templateFilter, setTemplateFilter] = useState('');
  295. const [settingsSearch, setSettingsSearch] = useState('');
  296. const [showLogViewer, setShowLogViewer] = useState(false);
  297. const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
  298. const [isRebuildLoading, setIsRebuildLoading] = useState(false);
  299. const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
  300. // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
  301. const tabParam = searchParams.get('tab');
  302. const isLegacyEmailTab = tabParam === 'email';
  303. // Camera tokens and the overlay builder used to live under API Keys; links
  304. // to them (bookmarks, the wiki, /camera-tokens) open the Camera tab instead.
  305. const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update');
  306. const isLegacyCameraLink = tabParam === 'apikeys'
  307. && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash);
  308. const initialTab = isLegacyEmailTab ? 'users'
  309. : isLegacyCameraLink ? 'camera'
  310. : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
  311. const [activeTab, setActiveTab] = useState<TabType>(initialTab);
  312. // Only Printer access is deep-linked (?tab=users&sub=printer-access), from the printer card menu
  313. const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(
  314. isLegacyEmailTab ? 'email' : tabParam === 'users' && searchParams.get('sub') === 'printer-access' ? 'printer-access' : 'users'
  315. );
  316. const selectUsersSubTab = (sub: UsersSubTab) => {
  317. setUsersSubTab(sub);
  318. if (sub === 'printer-access') {
  319. searchParams.set('sub', 'printer-access');
  320. } else {
  321. for (const key of ['sub', 'view', 'group', 'printer']) searchParams.delete(key);
  322. }
  323. setSearchParams(searchParams, { replace: true });
  324. };
  325. // A link to Printer access lands non-admins on the Users sub-tab instead
  326. // and the sign-in configuration sub-tabs, whose settings only an admin may change.
  327. const shownUsersSubTab: UsersSubTab =
  328. (usersSubTab === 'printer-access' && !(authEnabled && isAdmin)) ||
  329. ((usersSubTab === 'email' || usersSubTab === 'ldap') && !isAdmin)
  330. ? 'users'
  331. : usersSubTab;
  332. // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
  333. // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
  334. const initialQueueSub: 'dispatch' | 'pipelines' =
  335. tabParam === 'queue' && searchParams.get('sub') === 'pipelines' ? 'pipelines' : 'dispatch';
  336. const [queueSubTab, setQueueSubTab] = useState<'dispatch' | 'pipelines'>(initialQueueSub);
  337. // Update URL when tab changes
  338. const handleTabChange = (tab: TabType) => {
  339. setActiveTab(tab);
  340. if (tab === 'users') {
  341. setUsersSubTab('users');
  342. }
  343. // Sub-tab state belongs to the tab being left
  344. for (const key of ['sub', 'view', 'group', 'printer']) searchParams.delete(key);
  345. if (tab === 'queue') {
  346. setQueueSubTab('dispatch');
  347. }
  348. if (tab === 'general') {
  349. searchParams.delete('tab');
  350. } else {
  351. searchParams.set('tab', tab);
  352. }
  353. setSearchParams(searchParams, { replace: true });
  354. };
  355. // Any other API Keys link, for a user who cannot see that tab, opens Camera:
  356. // the camera tokens were the only thing such a user ever went there for.
  357. // Waits for the session so a permission not loaded yet doesn't count as none.
  358. useEffect(() => {
  359. if (!authLoading && activeTab === 'apikeys' && !canSeeApiKeysTab) handleTabChange('camera');
  360. // eslint-disable-next-line react-hooks/exhaustive-deps
  361. }, [authLoading, activeTab, canSeeApiKeysTab]);
  362. // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
  363. const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
  364. setQueueSubTab(sub);
  365. if (sub === 'pipelines') {
  366. searchParams.set('sub', 'pipelines');
  367. } else {
  368. searchParams.delete('sub');
  369. }
  370. setSearchParams(searchParams, { replace: true });
  371. };
  372. const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
  373. const [newAPIKeyName, setNewAPIKeyName] = useState('');
  374. const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
  375. can_queue: true,
  376. can_control_printer: false,
  377. can_read_status: true,
  378. can_manage_library: true,
  379. can_manage_inventory: true,
  380. can_manage_maintenance: true,
  381. can_manage_archives: true,
  382. can_manage_projects: true,
  383. can_access_cloud: false,
  384. can_update_energy_cost: false,
  385. can_send_notifications: false,
  386. });
  387. const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
  388. const [showApiKeyQR, setShowApiKeyQR] = useState(false);
  389. const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
  390. const [testApiKey, setTestApiKey] = useState('');
  391. // Confirm modal states
  392. const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
  393. const [showClearStorageConfirm, setShowClearStorageConfirm] = useState(false);
  394. const [showBulkPlugConfirm, setShowBulkPlugConfirm] = useState<'on' | 'off' | null>(null);
  395. const [showReleaseNotes, setShowReleaseNotes] = useState(false);
  396. const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
  397. const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
  398. const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
  399. const [changePasswordLoading, setChangePasswordLoading] = useState(false);
  400. const [storageUsageRefreshing, setStorageUsageRefreshing] = useState(false);
  401. // User management state
  402. const [showCreateUserModal, setShowCreateUserModal] = useState(false);
  403. // Local / LDAP tab inside the create-user modal (#1298).
  404. const [createUserTab, setCreateUserTab] = useState<'local' | 'ldap'>('local');
  405. const [showEditUserModal, setShowEditUserModal] = useState(false);
  406. const [editingUserId, setEditingUserId] = useState<number | null>(null);
  407. const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
  408. const [deleteUserItemCounts, setDeleteUserItemCounts] = useState<{ archives: number; queue_items: number; library_files: number } | null>(null);
  409. const [deleteUserLoading, setDeleteUserLoading] = useState(false);
  410. const [userFormData, setUserFormData] = useState<{
  411. username: string;
  412. password?: string;
  413. email?: string;
  414. confirmPassword: string;
  415. role: string;
  416. group_ids: number[];
  417. }>({
  418. username: '',
  419. password: '',
  420. email: '',
  421. confirmPassword: '',
  422. role: 'user',
  423. group_ids: [],
  424. });
  425. // Group management state
  426. const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
  427. // Home Assistant test connection state
  428. const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
  429. const [haTestLoading, setHaTestLoading] = useState(false);
  430. // External camera test state
  431. const [extCameraTestResults, setExtCameraTestResults] = useState<Record<number, { success: boolean; error?: string; resolution?: string } | null>>({});
  432. const [extCameraTestLoading, setExtCameraTestLoading] = useState<Record<number, boolean>>({});
  433. const handleDefaultViewChange = (path: string) => {
  434. setDefaultViewState(path);
  435. setDefaultView(path);
  436. showToast(t('settings.toast.settingsSaved'), 'success');
  437. };
  438. const { data: settings, isLoading } = useQuery({
  439. queryKey: ['settings'],
  440. queryFn: api.getSettings,
  441. });
  442. const {
  443. data: storageUsage,
  444. isLoading: storageUsageLoading,
  445. isFetching: storageUsageFetching,
  446. } = useQuery<StorageUsageResponse>({
  447. queryKey: ['storage-usage'],
  448. queryFn: () => api.getStorageUsage(),
  449. enabled: activeTab === 'general',
  450. staleTime: Infinity,
  451. refetchInterval: false,
  452. refetchOnWindowFocus: false,
  453. refetchOnReconnect: false,
  454. });
  455. // Price sensors to suggest when the electricity price comes from Home
  456. // Assistant (#1251): anything with a unit per kWh, MWh or Wh.
  457. const { data: haPriceEntities } = useQuery({
  458. queryKey: ['ha-price-entities'],
  459. queryFn: async () =>
  460. (await api.getBindableHAEntities()).filter(
  461. (e) => e.domain === 'sensor' && /\/\s*[km]?wh$/i.test(e.unit_of_measurement ?? ''),
  462. ),
  463. enabled: activeTab === 'general' && settings?.energy_price_source === 'homeassistant' && !!settings?.ha_enabled,
  464. staleTime: 60_000,
  465. retry: false,
  466. });
  467. const handleStorageUsageRefresh = async () => {
  468. setStorageUsageRefreshing(true);
  469. try {
  470. const data = await api.getStorageUsage({ refresh: true });
  471. queryClient.setQueryData(['storage-usage'], data);
  472. } catch (error) {
  473. const message = error instanceof Error ? error.message : 'Failed to refresh storage usage';
  474. showToast(message, 'error');
  475. } finally {
  476. setStorageUsageRefreshing(false);
  477. }
  478. };
  479. const { data: smartPlugs, isLoading: plugsLoading } = useQuery({
  480. queryKey: ['smart-plugs'],
  481. queryFn: api.getSmartPlugs,
  482. });
  483. // Fetch energy data for all smart plugs when on the plugs tab
  484. const { data: plugEnergySummary, isLoading: energyLoading } = useQuery({
  485. queryKey: ['smart-plugs-energy', smartPlugs?.map(p => p.id)],
  486. queryFn: async () => {
  487. if (!smartPlugs || smartPlugs.length === 0) return null;
  488. const statuses = await Promise.all(
  489. smartPlugs.filter(p => p.enabled).map(async (plug) => {
  490. try {
  491. const status = await api.getSmartPlugStatus(plug.id);
  492. return { plug, status };
  493. } catch {
  494. return { plug, status: null as SmartPlugStatus | null };
  495. }
  496. })
  497. );
  498. // Aggregate energy data
  499. let totalPower = 0;
  500. let totalToday = 0;
  501. let totalYesterday = 0;
  502. let totalLifetime = 0;
  503. let reachableCount = 0;
  504. for (const { plug, status } of statuses) {
  505. // For MQTT plugs, consider reachable if we have power data
  506. const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power != null);
  507. // Reachability is about the device answering, not about it measuring
  508. // anything. Requiring `status.energy` here counted a perfectly online
  509. // switch-only plug -- no power sensor, so the backend leaves `energy`
  510. // null -- as offline, and the counter labelled "plugs online" quietly
  511. // reported "plugs reporting energy" instead (#2859).
  512. const isReachable = status?.reachable || hasMqttData;
  513. if (isReachable) {
  514. reachableCount++;
  515. if (status?.energy?.power != null) totalPower += status.energy.power;
  516. if (status?.energy?.today != null) totalToday += status.energy.today;
  517. if (status?.energy?.yesterday != null) totalYesterday += status.energy.yesterday;
  518. if (status?.energy?.total != null) totalLifetime += status.energy.total;
  519. }
  520. }
  521. return {
  522. totalPower,
  523. totalToday,
  524. totalYesterday,
  525. totalLifetime,
  526. reachableCount,
  527. totalPlugs: smartPlugs.filter(p => p.enabled).length,
  528. };
  529. },
  530. enabled: activeTab === 'plugs' && !!smartPlugs && smartPlugs.length > 0,
  531. refetchInterval: activeTab === 'plugs' ? 10000 : false, // Refresh every 10s when on plugs tab
  532. });
  533. const { data: notificationProviders, isLoading: providersLoading } = useQuery({
  534. queryKey: ['notification-providers'],
  535. queryFn: api.getNotificationProviders,
  536. });
  537. const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
  538. queryKey: ['api-keys'],
  539. queryFn: api.getAPIKeys,
  540. });
  541. const createAPIKeyMutation = useMutation({
  542. mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean; can_manage_library: boolean; can_manage_inventory: boolean; can_manage_maintenance: boolean; can_manage_archives: boolean; can_manage_projects: boolean; can_access_cloud: boolean }) =>
  543. api.createAPIKey(data),
  544. onSuccess: (data) => {
  545. setCreatedAPIKey(data.key || null);
  546. setShowCreateAPIKey(false);
  547. setNewAPIKeyName('');
  548. queryClient.invalidateQueries({ queryKey: ['api-keys'] });
  549. showToast(t('settings.toast.apiKeyCreated'));
  550. },
  551. onError: (error: Error) => {
  552. showToast(`Failed to create API key: ${error.message}`, 'error');
  553. },
  554. });
  555. const deleteAPIKeyMutation = useMutation({
  556. mutationFn: (id: number) => api.deleteAPIKey(id),
  557. onSuccess: (_data, deletedId) => {
  558. queryClient.setQueryData<APIKey[]>(['api-keys'], (old) =>
  559. (old ?? []).filter((key) => key.id !== deletedId)
  560. );
  561. showToast(t('settings.toast.apiKeyDeleted'));
  562. },
  563. onError: (error: Error) => {
  564. showToast(`Failed to delete API key: ${error.message}`, 'error');
  565. },
  566. });
  567. const { data: printers } = useQuery({
  568. queryKey: ['printers'],
  569. queryFn: api.getPrinters,
  570. });
  571. const { data: haSensors } = useQuery({
  572. queryKey: ['haSensors'],
  573. queryFn: () => api.getHASensors(),
  574. });
  575. // Not polled — this is the sensor list (for the badge count and the
  576. // section below), not live readings. It only changes via create/edit/
  577. // delete, which already invalidate this key.
  578. const { data: locationHaSensors } = useQuery({
  579. queryKey: ['locationHaSensors'],
  580. queryFn: () => api.getLocationHASensors(),
  581. });
  582. const { data: haSensorLocations } = useQuery({
  583. queryKey: inventoryLocationsQueryKey,
  584. queryFn: api.getLocations,
  585. enabled: activeTab === 'sensors',
  586. });
  587. const locationSensorLocationIds = useMemo(
  588. () => Array.from(new Set((locationHaSensors ?? []).map((s) => s.location_id))),
  589. [locationHaSensors]
  590. );
  591. // Live readings (reachable-aware), fetched per location the same way
  592. // InventoryPage's table view and SpoolLocationFooter do — false to get
  593. // every bound sensor here, not just the ones marked to show on the
  594. // filament card. Same query key as those two (no 'all'/'cardOnly' suffix),
  595. // so navigating here after Inventory has already fetched a location is a
  596. // cache hit instead of a second request; navigating back does the same
  597. // for Inventory. Only runs while this tab is actually open.
  598. const locationSensorReadingsQueries = useQueries({
  599. queries: locationSensorLocationIds.map((locationId) => ({
  600. queryKey: ['locationHaSensorReadings', locationId],
  601. queryFn: () => api.getLocationHASensorReadings(locationId, false),
  602. enabled: activeTab === 'sensors',
  603. refetchInterval: activeTab === 'sensors' ? (settings?.location_sensor_poll_interval || 120) * 1000 : false,
  604. })),
  605. });
  606. const locationSensorReadingsById = useMemo(() => {
  607. const map = new Map<number, LocationHASensorReading>();
  608. for (const query of locationSensorReadingsQueries) {
  609. for (const reading of query.data ?? []) map.set(reading.id, reading);
  610. }
  611. return map;
  612. }, [locationSensorReadingsQueries]);
  613. const deleteLocationSensorsMutation = useMutation({
  614. mutationFn: async (sensorIds: number[]) => {
  615. for (const id of sensorIds) {
  616. await api.deleteLocationHASensor(id);
  617. }
  618. },
  619. onSuccess: () => {
  620. showToast(t('locationHaSensors.toast.deleted'), 'success');
  621. },
  622. onError: (err: Error) => {
  623. showToast(err.message, 'error');
  624. },
  625. // The mutation deletes sequentially; a failure partway through has
  626. // already deleted some sensors on the backend, so the cache needs
  627. // refreshing whether the mutation as a whole succeeded or failed —
  628. // otherwise the ones that did go through stay on screen as if nothing
  629. // happened.
  630. onSettled: () => {
  631. queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
  632. queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
  633. setDeleteLocationSensorsTarget(null);
  634. },
  635. });
  636. // A business-sized fleet gets the commercial ask instead of the donation ask.
  637. const sponsorAudience = fleetAudience(printers?.length ?? 0);
  638. const { data: notificationTemplates, isLoading: templatesLoading } = useQuery({
  639. queryKey: ['notification-templates'],
  640. queryFn: api.getNotificationTemplates,
  641. });
  642. // Virtual printer status for tab indicator
  643. const { data: virtualPrinterSettings } = useQuery({
  644. queryKey: ['virtual-printer-settings'],
  645. queryFn: virtualPrinterApi.getSettings,
  646. refetchInterval: 10000,
  647. });
  648. const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;
  649. // SpoolBuddy devices for tab indicator
  650. const { data: spoolbuddyDevices } = useQuery({
  651. queryKey: ['spoolbuddy-devices'],
  652. queryFn: () => spoolbuddyApi.getDevices(),
  653. refetchInterval: 15000,
  654. });
  655. const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0;
  656. const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false;
  657. // Obico failure-detection service status for tab indicator
  658. const { data: obicoStatus } = useQuery({
  659. queryKey: ['obico-status'],
  660. queryFn: api.getObicoStatus,
  661. refetchInterval: 15000,
  662. });
  663. const obicoActive = !!(obicoStatus?.is_running && obicoStatus?.enabled);
  664. const { data: ffmpegStatus } = useQuery({
  665. queryKey: ['ffmpeg-status'],
  666. queryFn: api.checkFfmpeg,
  667. });
  668. const { data: versionInfo } = useQuery({
  669. queryKey: ['version'],
  670. queryFn: api.getVersion,
  671. });
  672. // Library trash settings (#1008). Separate endpoint from the generic
  673. // /settings — persists retention window + auto-purge config. Admin-only.
  674. const canPurge = !authEnabled || hasPermission('library:purge');
  675. const { data: trashSettings } = useQuery({
  676. queryKey: ['library-trash-settings'],
  677. queryFn: () => api.getLibraryTrashSettings(),
  678. enabled: canPurge,
  679. });
  680. const updateTrashSettingsMutation = useMutation({
  681. mutationFn: (body: {
  682. retention_days: number;
  683. auto_purge_enabled: boolean;
  684. auto_purge_days: number;
  685. auto_purge_include_never_printed: boolean;
  686. }) => api.updateLibraryTrashSettings(body),
  687. onSuccess: () => {
  688. queryClient.invalidateQueries({ queryKey: ['library-trash-settings'] });
  689. showToast(t('settings.toast.settingsSaved'), 'success');
  690. },
  691. onError: (e: Error) => showToast(e.message || t('libraryAutoPurge.saveFailed'), 'error'),
  692. });
  693. const saveTrashSettings = (patch: Partial<{
  694. retention_days: number;
  695. auto_purge_enabled: boolean;
  696. auto_purge_days: number;
  697. auto_purge_include_never_printed: boolean;
  698. }>) => {
  699. if (!trashSettings) return;
  700. updateTrashSettingsMutation.mutate({
  701. retention_days: trashSettings.retention_days,
  702. auto_purge_enabled: trashSettings.auto_purge_enabled,
  703. auto_purge_days: trashSettings.auto_purge_days,
  704. auto_purge_include_never_printed: trashSettings.auto_purge_include_never_printed,
  705. ...patch,
  706. });
  707. };
  708. // Archive auto-purge (#1008 follow-up). Gated on the dedicated archives:purge
  709. // permission so admins can delegate bulk-delete to a role without granting
  710. // per-archive delete on other users' rows.
  711. const canPurgeArchives = !authEnabled || hasPermission('archives:purge');
  712. const { data: archivePurgeSettings } = useQuery({
  713. queryKey: ['archive-purge-settings'],
  714. queryFn: () => api.getArchivePurgeSettings(),
  715. enabled: canPurgeArchives,
  716. });
  717. const updateArchivePurgeSettingsMutation = useMutation({
  718. mutationFn: (body: { enabled: boolean; days: number; purge_stats: boolean }) =>
  719. api.updateArchivePurgeSettings(body),
  720. onSuccess: () => {
  721. queryClient.invalidateQueries({ queryKey: ['archive-purge-settings'] });
  722. showToast(t('settings.toast.settingsSaved'), 'success');
  723. },
  724. onError: (e: Error) => showToast(e.message || t('archiveAutoPurge.saveFailed'), 'error'),
  725. });
  726. const saveArchivePurgeSettings = (
  727. patch: Partial<{ enabled: boolean; days: number; purge_stats: boolean }>,
  728. ) => {
  729. if (!archivePurgeSettings) return;
  730. updateArchivePurgeSettingsMutation.mutate({
  731. enabled: archivePurgeSettings.enabled,
  732. days: archivePurgeSettings.days,
  733. purge_stats: archivePurgeSettings.purge_stats,
  734. ...patch,
  735. });
  736. };
  737. const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
  738. queryKey: ['updateCheck'],
  739. queryFn: api.checkForUpdates,
  740. enabled: settings?.check_updates !== false,
  741. staleTime: 5 * 60 * 1000,
  742. });
  743. const { data: updateStatus, refetch: refetchUpdateStatus } = useQuery({
  744. queryKey: ['updateStatus'],
  745. queryFn: api.getUpdateStatus,
  746. refetchInterval: (query) => {
  747. const status = query.state.data as UpdateStatus | undefined;
  748. // Poll while update is in progress
  749. if (status?.status === 'downloading' || status?.status === 'installing') {
  750. return 1000;
  751. }
  752. return false;
  753. },
  754. });
  755. // MQTT status for Network tab
  756. const { data: mqttStatus } = useQuery({
  757. queryKey: ['mqtt-status'],
  758. queryFn: api.getMQTTStatus,
  759. refetchInterval: activeTab === 'network' ? 5000 : false, // Poll every 5s when on Network tab
  760. });
  761. // GitHub backup status for Backup tab indicator
  762. const { data: githubBackupStatus } = useQuery<GitHubBackupStatus>({
  763. queryKey: ['github-backup-status'],
  764. queryFn: api.getGitHubBackupStatus,
  765. });
  766. // Cloud auth status for Backup tab indicator
  767. const { data: cloudAuthStatus } = useQuery<CloudAuthStatus>({
  768. queryKey: ['cloud-status'],
  769. queryFn: api.getCloudStatus,
  770. });
  771. // Advanced auth status for user creation
  772. const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false, local_login_enabled: true, autologin_provider_id: null } } = useQuery({
  773. queryKey: ['advancedAuthStatus'],
  774. queryFn: () => api.getAdvancedAuthStatus(),
  775. });
  776. const { data: ldapStatus } = useQuery({
  777. queryKey: ['ldapStatus'],
  778. queryFn: () => api.getLDAPStatus(),
  779. });
  780. // Tab-indicator queries: green bullet when 2FA is enabled for the current
  781. // user, or when at least one OIDC provider is configured and enabled.
  782. const { data: twoFAStatus } = useQuery({
  783. queryKey: ['twoFAStatus'],
  784. queryFn: () => api.get2FAStatus(),
  785. });
  786. const { data: oidcProvidersAll = [] } = useQuery({
  787. queryKey: ['oidcProvidersAll'],
  788. queryFn: () => api.getOIDCProvidersAll(),
  789. enabled: isAdmin,
  790. });
  791. // User management queries and mutations
  792. const { data: usersData = [], isLoading: usersLoading } = useQuery({
  793. queryKey: ['users'],
  794. queryFn: () => api.getUsers(),
  795. enabled: authEnabled && hasPermission('users:read'),
  796. });
  797. const { data: groupsData = [], isLoading: groupsLoading } = useQuery({
  798. queryKey: ['groups'],
  799. queryFn: () => api.getGroups(),
  800. enabled: authEnabled && hasPermission('groups:read'),
  801. });
  802. const createUserMutation = useMutation({
  803. mutationFn: (data: UserCreate) => api.createUser(data),
  804. onSuccess: () => {
  805. queryClient.invalidateQueries({ queryKey: ['users'] });
  806. queryClient.invalidateQueries({ queryKey: ['groups'] });
  807. setShowCreateUserModal(false);
  808. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  809. showToast(t('settings.toast.userCreated'));
  810. },
  811. onError: (error: Error) => {
  812. showToast(error.message, 'error');
  813. },
  814. });
  815. const updateUserMutation = useMutation({
  816. mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
  817. onSuccess: () => {
  818. queryClient.invalidateQueries({ queryKey: ['users'] });
  819. queryClient.invalidateQueries({ queryKey: ['groups'] });
  820. setShowEditUserModal(false);
  821. setEditingUserId(null);
  822. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  823. showToast(t('settings.toast.userUpdated'));
  824. },
  825. onError: (error: Error) => {
  826. showToast(error.message, 'error');
  827. },
  828. });
  829. const deleteUserMutation = useMutation({
  830. mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems),
  831. onSuccess: () => {
  832. queryClient.invalidateQueries({ queryKey: ['users'] });
  833. showToast(t('settings.toast.userDeleted'));
  834. setDeleteUserId(null);
  835. setDeleteUserItemCounts(null);
  836. },
  837. onError: (error: Error) => {
  838. showToast(error.message, 'error');
  839. },
  840. });
  841. const resetPasswordMutation = useMutation({
  842. mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),
  843. onSuccess: (response) => {
  844. showToast(response.message, 'success');
  845. },
  846. onError: (error: Error) => {
  847. showToast(error.message, 'error');
  848. },
  849. });
  850. // Function to initiate user deletion with item count check
  851. const handleDeleteUserClick = async (userId: number) => {
  852. setDeleteUserId(userId);
  853. setDeleteUserLoading(true);
  854. try {
  855. const counts = await api.getUserItemsCount(userId);
  856. setDeleteUserItemCounts(counts);
  857. } catch {
  858. // If we can't get counts, just proceed without showing item options
  859. setDeleteUserItemCounts({ archives: 0, queue_items: 0, library_files: 0 });
  860. } finally {
  861. setDeleteUserLoading(false);
  862. }
  863. };
  864. const deleteGroupMutation = useMutation({
  865. mutationFn: (id: number) => api.deleteGroup(id),
  866. onSuccess: () => {
  867. queryClient.invalidateQueries({ queryKey: ['groups'] });
  868. showToast(t('settings.toast.groupDeleted'));
  869. },
  870. onError: (error: Error) => {
  871. showToast(error.message, 'error');
  872. },
  873. });
  874. // User management handlers
  875. const handleCreateUser = () => {
  876. // Use the status from the query hook
  877. const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
  878. if (!userFormData.username) {
  879. showToast(t('settings.toast.fillRequiredFields'), 'error');
  880. return;
  881. }
  882. // Email is required when advanced auth is enabled
  883. if (advancedAuthEnabled && !userFormData.email) {
  884. showToast('Email is required when advanced authentication is enabled', 'error');
  885. return;
  886. }
  887. // Password validation only when advanced auth is disabled
  888. if (!advancedAuthEnabled) {
  889. if (!userFormData.password) {
  890. showToast(t('settings.toast.fillRequiredFields'), 'error');
  891. return;
  892. }
  893. if (userFormData.password !== userFormData.confirmPassword) {
  894. showToast(t('settings.toast.passwordsDoNotMatch'), 'error');
  895. return;
  896. }
  897. const complexityIssue = checkPasswordComplexity(userFormData.password);
  898. if (complexityIssue) {
  899. const issueToKey = {
  900. tooShort: 'settings.toast.passwordTooShort',
  901. needsUppercase: 'settings.toast.passwordNeedsUppercase',
  902. needsLowercase: 'settings.toast.passwordNeedsLowercase',
  903. needsDigit: 'settings.toast.passwordNeedsDigit',
  904. needsSpecial: 'settings.toast.passwordNeedsSpecial',
  905. } as const;
  906. showToast(t(issueToKey[complexityIssue]), 'error');
  907. return;
  908. }
  909. }
  910. createUserMutation.mutate({
  911. username: userFormData.username,
  912. password: advancedAuthEnabled ? undefined : userFormData.password,
  913. email: userFormData.email || undefined,
  914. role: userFormData.role,
  915. group_ids: userFormData.group_ids.length > 0 ? userFormData.group_ids : undefined,
  916. });
  917. };
  918. const handleUpdateUser = (id: number) => {
  919. if (userFormData.password) {
  920. if (userFormData.password !== userFormData.confirmPassword) {
  921. showToast(t('settings.toast.passwordsDoNotMatch'), 'error');
  922. return;
  923. }
  924. const complexityIssue = checkPasswordComplexity(userFormData.password);
  925. if (complexityIssue) {
  926. const issueToKey = {
  927. tooShort: 'settings.toast.passwordTooShort',
  928. needsUppercase: 'settings.toast.passwordNeedsUppercase',
  929. needsLowercase: 'settings.toast.passwordNeedsLowercase',
  930. needsDigit: 'settings.toast.passwordNeedsDigit',
  931. needsSpecial: 'settings.toast.passwordNeedsSpecial',
  932. } as const;
  933. showToast(t(issueToKey[complexityIssue]), 'error');
  934. return;
  935. }
  936. }
  937. const updateData: UserUpdate = {
  938. username: userFormData.username || undefined,
  939. password: userFormData.password || undefined,
  940. // Empty removes the email; the backend reads null as "unchanged".
  941. email: userFormData.email,
  942. role: userFormData.role,
  943. group_ids: userFormData.group_ids,
  944. };
  945. if (!updateData.password) {
  946. delete updateData.password;
  947. }
  948. updateUserMutation.mutate({ id, data: updateData });
  949. };
  950. const startEditUser = (userToEdit: UserResponse) => {
  951. setEditingUserId(userToEdit.id);
  952. setUserFormData({
  953. username: userToEdit.username,
  954. password: '',
  955. email: userToEdit.email || '',
  956. confirmPassword: '',
  957. role: userToEdit.role,
  958. group_ids: userToEdit.groups?.map(g => g.id) || [],
  959. });
  960. setShowEditUserModal(true);
  961. };
  962. const toggleUserGroup = (groupId: number) => {
  963. setUserFormData(prev => ({
  964. ...prev,
  965. group_ids: prev.group_ids.includes(groupId)
  966. ? prev.group_ids.filter(id => id !== groupId)
  967. : [...prev.group_ids, groupId],
  968. }));
  969. };
  970. const applyUpdateMutation = useMutation({
  971. mutationFn: api.applyUpdate,
  972. onSuccess: (data) => {
  973. if (data.is_ha_addon || data.is_docker || data.is_windows_installer) {
  974. showToast(data.message, 'error');
  975. } else {
  976. refetchUpdateStatus();
  977. }
  978. },
  979. });
  980. // Test all notification providers
  981. const [testAllResult, setTestAllResult] = useState<{
  982. tested: number;
  983. success: number;
  984. failed: number;
  985. results: Array<{
  986. provider_id: number;
  987. provider_name: string;
  988. provider_type: string;
  989. success: boolean;
  990. message: string;
  991. }>;
  992. } | null>(null);
  993. const testAllMutation = useMutation({
  994. mutationFn: api.testAllNotificationProviders,
  995. onSuccess: (data) => {
  996. setTestAllResult(data);
  997. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  998. if (data.failed === 0) {
  999. showToast(`All ${data.tested} providers tested successfully!`, 'success');
  1000. } else {
  1001. showToast(`${data.success}/${data.tested} providers succeeded`, data.failed > 0 ? 'error' : 'success');
  1002. }
  1003. },
  1004. onError: (error: Error) => {
  1005. showToast(`Failed to test providers: ${error.message}`, 'error');
  1006. },
  1007. });
  1008. // Bulk action for smart plugs
  1009. const bulkPlugActionMutation = useMutation({
  1010. mutationFn: async (action: 'on' | 'off') => {
  1011. if (!smartPlugs) return { success: 0, failed: 0 };
  1012. const enabledPlugs = smartPlugs.filter(p => p.enabled);
  1013. const results = await Promise.all(
  1014. enabledPlugs.map(async (plug) => {
  1015. try {
  1016. await api.controlSmartPlug(plug.id, action);
  1017. return { success: true };
  1018. } catch {
  1019. return { success: false };
  1020. }
  1021. })
  1022. );
  1023. return {
  1024. success: results.filter(r => r.success).length,
  1025. failed: results.filter(r => !r.success).length,
  1026. };
  1027. },
  1028. onSuccess: (data, action) => {
  1029. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  1030. queryClient.invalidateQueries({ queryKey: ['smart-plugs-energy'] });
  1031. if (data.failed === 0) {
  1032. showToast(`All ${data.success} plugs turned ${action}`, 'success');
  1033. } else {
  1034. showToast(`${data.success} plugs turned ${action}, ${data.failed} failed`, 'error');
  1035. }
  1036. },
  1037. onError: (error: Error) => {
  1038. showToast(`Failed: ${error.message}`, 'error');
  1039. },
  1040. });
  1041. // Ref for debounce timeout
  1042. const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  1043. const pendingGcodeSnippetsRef = useRef<string | null>(null);
  1044. const isSavingRef = useRef(false);
  1045. const isInitialLoadRef = useRef(true);
  1046. // #2716: the last server snapshot this page reconciled with. It is what
  1047. // makes "the user edited this field" a well-defined question: a field where
  1048. // localSettings still equals the baseline has not been touched since that
  1049. // reconcile, so a newer server value can be taken instead of the page's stale
  1050. // copy being written back over it. Before this the debounced save diffed
  1051. // against the live ['settings'] cache, which made a value changed on the
  1052. // server -- another tab, another user, a backup restore, a refetch driven by
  1053. // any of the ~30 other observers of the key -- indistinguishable from an edit,
  1054. // and reverted it a few hundred ms later with no user interaction at all.
  1055. const serverBaselineRef = useRef<AppSettings | null>(null);
  1056. // Sync local state when settings load
  1057. useEffect(() => {
  1058. if (settings && !localSettings) {
  1059. // Auto-detect external_url from browser if not set. Admins only: only an
  1060. // admin may change it, and the detected value is saved like an edit.
  1061. const settingsWithExternalUrl = {
  1062. ...settings,
  1063. external_url: settings.external_url || (isAdmin ? window.location.origin : settings.external_url),
  1064. };
  1065. // The baseline is the raw server row, not this adjusted copy: a detected
  1066. // external_url has to read as a local change so it still gets persisted.
  1067. serverBaselineRef.current = settings;
  1068. setLocalSettings(settingsWithExternalUrl);
  1069. // Mark initial load complete after a short delay
  1070. setTimeout(() => {
  1071. isInitialLoadRef.current = false;
  1072. }, 100);
  1073. }
  1074. }, [settings, localSettings, isAdmin]);
  1075. // #2716: reconcile a moved server snapshot into the local copy. A field the
  1076. // user has not touched since the last reconcile takes the server's value; a
  1077. // field they have edited keeps theirs and is saved over it by the debounced
  1078. // effect below, so the newer of the two writes wins either way. Declared
  1079. // before that effect so the baseline has already moved by the time it
  1080. // computes its diff in the same commit.
  1081. useEffect(() => {
  1082. const baseline = serverBaselineRef.current;
  1083. if (!settings || !localSettings || !baseline || settings === baseline) {
  1084. return;
  1085. }
  1086. const adopted: Record<string, unknown> = {};
  1087. for (const key of Object.keys(settings) as (keyof AppSettings)[]) {
  1088. if (settings[key] !== baseline[key] && localSettings[key] === baseline[key]) {
  1089. adopted[key] = settings[key];
  1090. }
  1091. }
  1092. serverBaselineRef.current = settings;
  1093. if (Object.keys(adopted).length > 0) {
  1094. setLocalSettings(prev => (prev ? { ...prev, ...(adopted as Partial<AppSettings>) } : prev));
  1095. }
  1096. }, [settings, localSettings]);
  1097. const updateMutation = useMutation({
  1098. mutationFn: api.updateSettings,
  1099. onSuccess: (data) => {
  1100. // #2716: the row we just saved becomes the snapshot to diff against.
  1101. // The setQueryData below would normally get the effect above to do this,
  1102. // but only if react-query hands back a new object; setting it here means
  1103. // the baseline never lags behind a save regardless.
  1104. serverBaselineRef.current = data;
  1105. queryClient.setQueryData(['settings'], data);
  1106. // Choosing a price sensor reads it on save; show that price (#1251).
  1107. if (data.energy_price_source === 'homeassistant') {
  1108. setLocalSettings(prev => (prev ? { ...prev, energy_cost_per_kwh: data.energy_cost_per_kwh } : prev));
  1109. }
  1110. // Don't call setLocalSettings(data) here — it would overwrite in-progress
  1111. // user input (e.g. typing a hostname) with the stale saved snapshot,
  1112. // causing the text field to reset mid-typing. Instead, let the useEffect
  1113. // re-compare the updated `settings` with current `localSettings` and
  1114. // debounce-save any remaining differences.
  1115. queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
  1116. // /settings/ui-flags serves currency and the sidebar gates to users who
  1117. // cannot read /settings. Nothing invalidated it, so a currency change
  1118. // sat behind that query's own staleTime instead of showing up (#3123).
  1119. queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
  1120. queryClient.invalidateQueries({ queryKey: ['ui-settings'] });
  1121. // Switching announcements off (or to all users) changes who sees the
  1122. // sidebar entry and the banner; don't wait for the next poll.
  1123. queryClient.invalidateQueries({ queryKey: ['announcements'] });
  1124. showToast(t('settings.toast.settingsSaved'), 'success');
  1125. },
  1126. onError: (error: Error) => {
  1127. showToast(`Failed to save: ${error.message}`, 'error');
  1128. // No localSettings rollback here — the existing comment above (see
  1129. // onSuccess) already flags that overwriting localSettings would discard
  1130. // in-progress user input (e.g. typing a hostname). The no-permission
  1131. // loop is already prevented by the up-front guards in updateSetting and
  1132. // in the debounced-save effect, so this onError path now only fires for
  1133. // genuine server/network failures where preserving typed-in values is
  1134. // the right call.
  1135. },
  1136. onSettled: () => {
  1137. // Reset saving flag when mutation completes (success or error)
  1138. isSavingRef.current = false;
  1139. },
  1140. });
  1141. const updatePrinterMutation = useMutation({
  1142. mutationFn: ({ id, data }: { id: number; data: Partial<{ external_camera_url: string | null; external_camera_type: string | null; external_camera_enabled: boolean; external_camera_snapshot_url: string | null; camera_rotation: number; camera_light_auto: boolean }> }) =>
  1143. api.updatePrinter(id, data),
  1144. onSuccess: () => {
  1145. queryClient.invalidateQueries({ queryKey: ['printers'] });
  1146. showToast(t('settings.toast.cameraSettingsSaved'), 'success');
  1147. },
  1148. onError: (error: Error) => {
  1149. showToast(`Failed to update printer: ${error.message}`, 'error');
  1150. },
  1151. });
  1152. // Debounced auto-save when localSettings change
  1153. useEffect(() => {
  1154. // Skip if initial load or no settings
  1155. const baseline = serverBaselineRef.current;
  1156. if (isInitialLoadRef.current || !localSettings || !settings || !baseline) {
  1157. return;
  1158. }
  1159. // Safety net: skip auto-save entirely when the user lacks settings:update.
  1160. // The actual user feedback (toast + revert) lives in updateSetting below,
  1161. // which runs once per click. Doing it here as well would fire on every
  1162. // React render since the debounced-save effect depends on non-stable refs.
  1163. if (authEnabled && !hasPermission('settings:update')) {
  1164. return;
  1165. }
  1166. // A saved secret does not follow a changed address: hold the save until it
  1167. // is entered again (the field says so), instead of failing on every pause.
  1168. if (secretsToReenter(baseline, localSettings, settingsSecretDestinations(baseline)).length > 0) {
  1169. return;
  1170. }
  1171. // Check if there are actual changes
  1172. const hasChanges =
  1173. baseline.auto_archive !== localSettings.auto_archive ||
  1174. baseline.capture_finish_photo !== localSettings.capture_finish_photo ||
  1175. (baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
  1176. baseline.default_filament_cost !== localSettings.default_filament_cost ||
  1177. baseline.currency !== localSettings.currency ||
  1178. (localSettings.energy_price_source !== 'homeassistant' &&
  1179. baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh) ||
  1180. baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
  1181. (baseline.energy_price_source ?? 'fixed') !== (localSettings.energy_price_source ?? 'fixed') ||
  1182. (baseline.energy_price_ha_entity ?? '') !== (localSettings.energy_price_ha_entity ?? '') ||
  1183. baseline.check_updates !== localSettings.check_updates ||
  1184. (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
  1185. (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
  1186. (baseline.announcements_enabled ?? true) !== (localSettings.announcements_enabled ?? true) ||
  1187. (baseline.announcements_all_users ?? false) !== (localSettings.announcements_all_users ?? false) ||
  1188. (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
  1189. (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
  1190. clampPauseWarningMinutes(baseline.pause_warning_minutes) !== clampPauseWarningMinutes(localSettings.pause_warning_minutes) ||
  1191. baseline.ams_humidity_good !== localSettings.ams_humidity_good ||
  1192. baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
  1193. baseline.ams_temp_good !== localSettings.ams_temp_good ||
  1194. baseline.ams_temp_fair !== localSettings.ams_temp_fair ||
  1195. (baseline.ams_temp_alarm ?? null) !== (localSettings.ams_temp_alarm ?? null) ||
  1196. baseline.ams_history_retention_days !== localSettings.ams_history_retention_days ||
  1197. baseline.printer_sensor_history_retention_days !== localSettings.printer_sensor_history_retention_days ||
  1198. baseline.disable_filament_warnings !== localSettings.disable_filament_warnings ||
  1199. baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
  1200. (baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
  1201. (baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
  1202. (baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
  1203. (baseline.ambient_drying_sustained_minutes ?? 0) !== (localSettings.ambient_drying_sustained_minutes ?? 0) ||
  1204. (baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
  1205. (baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
  1206. (baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
  1207. baseline.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
  1208. baseline.date_format !== localSettings.date_format ||
  1209. baseline.time_format !== localSettings.time_format ||
  1210. baseline.default_printer_id !== localSettings.default_printer_id ||
  1211. (baseline.pipeline_max_copies ?? 50) !== (localSettings.pipeline_max_copies ?? 50) ||
  1212. (baseline.docker_compose_dir ?? '') !== (localSettings.docker_compose_dir ?? '') ||
  1213. baseline.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
  1214. baseline.ftp_retry_count !== localSettings.ftp_retry_count ||
  1215. baseline.ftp_retry_delay !== localSettings.ftp_retry_delay ||
  1216. baseline.ftp_timeout !== localSettings.ftp_timeout ||
  1217. baseline.mqtt_enabled !== localSettings.mqtt_enabled ||
  1218. baseline.mqtt_broker !== localSettings.mqtt_broker ||
  1219. baseline.mqtt_port !== localSettings.mqtt_port ||
  1220. baseline.mqtt_username !== localSettings.mqtt_username ||
  1221. baseline.mqtt_password !== localSettings.mqtt_password ||
  1222. baseline.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
  1223. baseline.mqtt_use_tls !== localSettings.mqtt_use_tls ||
  1224. baseline.external_url !== localSettings.external_url ||
  1225. baseline.ha_enabled !== localSettings.ha_enabled ||
  1226. baseline.ha_url !== localSettings.ha_url ||
  1227. baseline.ha_token !== localSettings.ha_token ||
  1228. Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
  1229. (baseline.camera_light_mode ?? 'off') !== (localSettings.camera_light_mode ?? 'off') ||
  1230. Number(baseline.camera_light_delay ?? 2) !== Number(localSettings.camera_light_delay ?? 2) ||
  1231. (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
  1232. resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) ||
  1233. (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
  1234. (baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
  1235. (baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
  1236. (baseline.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
  1237. (baseline.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
  1238. baseline.prometheus_enabled !== localSettings.prometheus_enabled ||
  1239. baseline.prometheus_token !== localSettings.prometheus_token ||
  1240. (baseline.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
  1241. (baseline.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
  1242. (baseline.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
  1243. (baseline.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
  1244. (baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
  1245. (baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
  1246. (baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
  1247. (baseline.default_confirm_outcome ?? false) !== (localSettings.default_confirm_outcome ?? false) ||
  1248. (baseline.confirm_outcome_external_prints ?? false) !== (localSettings.confirm_outcome_external_prints ?? false) ||
  1249. (baseline.confirm_default_good_on_plate_clear ?? false) !== (localSettings.confirm_default_good_on_plate_clear ?? false) ||
  1250. (baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
  1251. (baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
  1252. (baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
  1253. (baseline.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
  1254. (baseline.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
  1255. (baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
  1256. (baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
  1257. (baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
  1258. (baseline.queue_keep_bed_warm ?? false) !== (localSettings.queue_keep_bed_warm ?? false) ||
  1259. (baseline.queue_keep_warm_bed_temp ?? 90) !== (localSettings.queue_keep_warm_bed_temp ?? 90) ||
  1260. (baseline.queue_keep_warm_max_minutes ?? 120) !== (localSettings.queue_keep_warm_max_minutes ?? 120) ||
  1261. (baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
  1262. (baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
  1263. (baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
  1264. (baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
  1265. (baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24) ||
  1266. (baseline.billing_enabled ?? false) !== (localSettings.billing_enabled ?? false) ||
  1267. (baseline.printer_kill_switch_enabled ?? false) !== (localSettings.printer_kill_switch_enabled ?? false) ||
  1268. (baseline.finance_budget_reset_day ?? 1) !== (localSettings.finance_budget_reset_day ?? 1) ||
  1269. (baseline.finance_budget_reset_timezone ?? 'UTC') !== (localSettings.finance_budget_reset_timezone ?? 'UTC');
  1270. if (!hasChanges) {
  1271. return;
  1272. }
  1273. // Don't queue more saves while one is in progress
  1274. if (isSavingRef.current) {
  1275. return;
  1276. }
  1277. // Clear existing timeout
  1278. if (saveTimeoutRef.current) {
  1279. clearTimeout(saveTimeoutRef.current);
  1280. }
  1281. // Set new debounced save (500ms delay)
  1282. saveTimeoutRef.current = setTimeout(() => {
  1283. // Skip if a save is already in progress
  1284. if (isSavingRef.current) {
  1285. return;
  1286. }
  1287. isSavingRef.current = true;
  1288. // Only send the fields we manage on this page (exclude virtual_printer_* which are managed separately)
  1289. const settingsToSave: AppSettingsUpdate = {
  1290. auto_archive: localSettings.auto_archive,
  1291. capture_finish_photo: localSettings.capture_finish_photo,
  1292. // #2547: `?? true` mirrors the toggle's own default, so an install
  1293. // whose settings payload predates this field saves what the user is
  1294. // actually looking at rather than `undefined`.
  1295. finish_photo_restore_plate: localSettings.finish_photo_restore_plate ?? true,
  1296. default_filament_cost: localSettings.default_filament_cost,
  1297. currency: localSettings.currency,
  1298. // A price read from Home Assistant is the server's to set; the copy
  1299. // here can be an hour old and would overwrite a newer reading (#1251).
  1300. ...(localSettings.energy_price_source === 'homeassistant'
  1301. ? {}
  1302. : { energy_cost_per_kwh: localSettings.energy_cost_per_kwh }),
  1303. energy_tracking_mode: localSettings.energy_tracking_mode,
  1304. // Normalised: anything but 'homeassistant' (a value restored from a
  1305. // backup) is fixed, and the backend refuses unknown values outright.
  1306. energy_price_source: localSettings.energy_price_source === 'homeassistant' ? 'homeassistant' : 'fixed',
  1307. energy_price_ha_entity: localSettings.energy_price_ha_entity ?? '',
  1308. check_updates: localSettings.check_updates,
  1309. check_printer_firmware: localSettings.check_printer_firmware,
  1310. include_beta_updates: localSettings.include_beta_updates,
  1311. announcements_enabled: localSettings.announcements_enabled ?? true,
  1312. announcements_all_users: localSettings.announcements_all_users ?? false,
  1313. local_login_enabled: localSettings.local_login_enabled,
  1314. bed_cooled_threshold: localSettings.bed_cooled_threshold,
  1315. pause_warning_minutes: clampPauseWarningMinutes(localSettings.pause_warning_minutes),
  1316. ams_humidity_good: localSettings.ams_humidity_good,
  1317. ams_humidity_fair: localSettings.ams_humidity_fair,
  1318. ams_temp_good: localSettings.ams_temp_good,
  1319. ams_temp_fair: localSettings.ams_temp_fair,
  1320. ams_temp_alarm: localSettings.ams_temp_alarm ?? null,
  1321. ams_history_retention_days: localSettings.ams_history_retention_days,
  1322. printer_sensor_history_retention_days: localSettings.printer_sensor_history_retention_days,
  1323. disable_filament_warnings: localSettings.disable_filament_warnings,
  1324. prefer_lowest_filament: localSettings.prefer_lowest_filament,
  1325. queue_drying_enabled: localSettings.queue_drying_enabled,
  1326. queue_drying_block: localSettings.queue_drying_block,
  1327. ambient_drying_enabled: localSettings.ambient_drying_enabled,
  1328. ambient_drying_sustained_minutes: localSettings.ambient_drying_sustained_minutes,
  1329. print_drying_enabled: localSettings.print_drying_enabled,
  1330. drying_presets: localSettings.drying_presets,
  1331. ams_humidity_thresholds: localSettings.ams_humidity_thresholds,
  1332. per_printer_mapping_expanded: localSettings.per_printer_mapping_expanded,
  1333. date_format: localSettings.date_format,
  1334. time_format: localSettings.time_format,
  1335. default_printer_id: localSettings.default_printer_id,
  1336. pipeline_max_copies: localSettings.pipeline_max_copies ?? 50,
  1337. docker_compose_dir: localSettings.docker_compose_dir ?? '',
  1338. ftp_retry_enabled: localSettings.ftp_retry_enabled,
  1339. ftp_retry_count: localSettings.ftp_retry_count,
  1340. ftp_retry_delay: localSettings.ftp_retry_delay,
  1341. ftp_timeout: localSettings.ftp_timeout,
  1342. mqtt_enabled: localSettings.mqtt_enabled,
  1343. mqtt_broker: localSettings.mqtt_broker,
  1344. mqtt_port: localSettings.mqtt_port,
  1345. mqtt_username: localSettings.mqtt_username,
  1346. mqtt_password: localSettings.mqtt_password,
  1347. mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
  1348. mqtt_use_tls: localSettings.mqtt_use_tls,
  1349. external_url: localSettings.external_url,
  1350. ha_enabled: localSettings.ha_enabled,
  1351. ha_url: localSettings.ha_url,
  1352. ha_token: localSettings.ha_token,
  1353. library_disk_warning_gb: localSettings.library_disk_warning_gb,
  1354. camera_light_mode: localSettings.camera_light_mode,
  1355. camera_light_delay: localSettings.camera_light_delay,
  1356. preferred_slicer: localSettings.preferred_slicer,
  1357. slice_engine: localSettings.slice_engine,
  1358. open_in_slicer: localSettings.open_in_slicer,
  1359. use_slicer_api: localSettings.use_slicer_api,
  1360. orcaslicer_api_url: localSettings.orcaslicer_api_url,
  1361. slicer_stall_timeout_minutes: localSettings.slicer_stall_timeout_minutes,
  1362. bambu_studio_api_url: localSettings.bambu_studio_api_url,
  1363. prometheus_enabled: localSettings.prometheus_enabled,
  1364. prometheus_token: localSettings.prometheus_token,
  1365. user_notifications_enabled: localSettings.user_notifications_enabled,
  1366. default_bed_levelling: localSettings.default_bed_levelling,
  1367. default_flow_cali: localSettings.default_flow_cali,
  1368. default_vibration_cali: localSettings.default_vibration_cali,
  1369. default_layer_inspect: localSettings.default_layer_inspect,
  1370. default_timelapse: localSettings.default_timelapse,
  1371. default_nozzle_offset_cali: localSettings.default_nozzle_offset_cali,
  1372. default_confirm_outcome: localSettings.default_confirm_outcome,
  1373. confirm_outcome_external_prints: localSettings.confirm_outcome_external_prints,
  1374. confirm_default_good_on_plate_clear: localSettings.confirm_default_good_on_plate_clear,
  1375. stagger_group_size: localSettings.stagger_group_size,
  1376. stagger_interval_minutes: localSettings.stagger_interval_minutes,
  1377. require_plate_clear: localSettings.require_plate_clear,
  1378. queue_max_concurrent_uploads: localSettings.queue_max_concurrent_uploads,
  1379. preheat_enabled: localSettings.preheat_enabled,
  1380. preheat_filament_targets: localSettings.preheat_filament_targets,
  1381. preheat_max_wait_seconds: localSettings.preheat_max_wait_seconds,
  1382. preheat_soak_seconds: localSettings.preheat_soak_seconds,
  1383. queue_keep_bed_warm: localSettings.queue_keep_bed_warm,
  1384. queue_keep_warm_bed_temp: localSettings.queue_keep_warm_bed_temp,
  1385. queue_keep_warm_max_minutes: localSettings.queue_keep_warm_max_minutes,
  1386. nozzle_temp_presets: localSettings.nozzle_temp_presets,
  1387. bed_temp_presets: localSettings.bed_temp_presets,
  1388. chamber_temp_presets: localSettings.chamber_temp_presets,
  1389. fan_speed_presets: localSettings.fan_speed_presets,
  1390. session_max_hours: localSettings.session_max_hours,
  1391. billing_enabled: localSettings.billing_enabled,
  1392. printer_kill_switch_enabled: localSettings.printer_kill_switch_enabled,
  1393. finance_budget_reset_day: localSettings.finance_budget_reset_day,
  1394. finance_budget_reset_timezone: localSettings.finance_budget_reset_timezone,
  1395. };
  1396. updateMutation.mutate(settingsToSave);
  1397. }, 500);
  1398. // Cleanup on unmount or when localSettings changes again
  1399. return () => {
  1400. if (saveTimeoutRef.current) {
  1401. clearTimeout(saveTimeoutRef.current);
  1402. }
  1403. };
  1404. }, [localSettings, settings, updateMutation, authEnabled, hasPermission, showToast, t]);
  1405. const secretsPendingReentry =
  1406. settings && localSettings ? secretsToReenter(settings, localSettings, settingsSecretDestinations(settings)) : [];
  1407. const updateSetting = useCallback(<K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
  1408. // Gate at the point of user interaction (not in the debounced-save effect —
  1409. // that runs on every render and would fire the toast repeatedly). One toast
  1410. // per attempt; no local state divergence for a read-only delegated user.
  1411. if (authEnabled && !hasPermission('settings:update')) {
  1412. showToast(t('settings.toast.noPermissionUpdate'), 'error');
  1413. return;
  1414. }
  1415. setLocalSettings(prev => prev ? { ...prev, [key]: value } : null);
  1416. }, [authEnabled, hasPermission, showToast, t]);
  1417. const handleTestExternalCamera = async (printerId: number, url: string, cameraType: string) => {
  1418. if (!url) {
  1419. showToast(t('settings.toast.enterCameraUrl'), 'error');
  1420. return;
  1421. }
  1422. setExtCameraTestLoading(prev => ({ ...prev, [printerId]: true }));
  1423. setExtCameraTestResults(prev => ({ ...prev, [printerId]: null }));
  1424. try {
  1425. const result = await api.testExternalCamera(printerId, url, cameraType);
  1426. setExtCameraTestResults(prev => ({ ...prev, [printerId]: result }));
  1427. if (result.success) {
  1428. showToast(cameraTestSuccessMessage(result, t), 'success');
  1429. } else {
  1430. showToast(result.error || t('settings.toast.connectionFailed'), 'error');
  1431. }
  1432. } catch (error) {
  1433. const message = error instanceof Error ? error.message : t('settings.toast.testFailed');
  1434. setExtCameraTestResults(prev => ({ ...prev, [printerId]: { success: false, error: message } }));
  1435. showToast(message, 'error');
  1436. } finally {
  1437. setExtCameraTestLoading(prev => ({ ...prev, [printerId]: false }));
  1438. }
  1439. };
  1440. // Local state for camera URL inputs (to avoid saving on every keystroke)
  1441. const [localCameraUrls, setLocalCameraUrls] = useState<Record<number, string>>({});
  1442. const cameraUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
  1443. const initializedPrinterUrlsRef = useRef<Set<number>>(new Set());
  1444. const [localSnapshotUrls, setLocalSnapshotUrls] = useState<Record<number, string>>({});
  1445. const snapshotUrlSaveTimeoutRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
  1446. const [openCameraPrinterId, setOpenCameraPrinterId] = useState<number | null>(null);
  1447. // USB cameras whose device path is typed instead of picked from the list.
  1448. const [usbManualEntry, setUsbManualEntry] = useState<Record<number, boolean>>({});
  1449. const initializedPrinterSnapshotUrlsRef = useRef<Set<number>>(new Set());
  1450. // Initialize local camera URLs from printer data
  1451. useEffect(() => {
  1452. if (printers) {
  1453. const urls: Record<number, string> = {};
  1454. const snapUrls: Record<number, string> = {};
  1455. printers.forEach(p => {
  1456. if (p.external_camera_url && !initializedPrinterUrlsRef.current.has(p.id)) {
  1457. urls[p.id] = p.external_camera_url;
  1458. initializedPrinterUrlsRef.current.add(p.id);
  1459. }
  1460. if (p.external_camera_snapshot_url && !initializedPrinterSnapshotUrlsRef.current.has(p.id)) {
  1461. snapUrls[p.id] = p.external_camera_snapshot_url;
  1462. initializedPrinterSnapshotUrlsRef.current.add(p.id);
  1463. }
  1464. });
  1465. if (Object.keys(urls).length > 0) {
  1466. setLocalCameraUrls(prev => ({ ...prev, ...urls }));
  1467. }
  1468. if (Object.keys(snapUrls).length > 0) {
  1469. setLocalSnapshotUrls(prev => ({ ...prev, ...snapUrls }));
  1470. }
  1471. }
  1472. }, [printers]);
  1473. const handleCameraUrlChange = (printerId: number, url: string) => {
  1474. // Update local state immediately for responsive UI
  1475. setLocalCameraUrls(prev => ({ ...prev, [printerId]: url }));
  1476. // Clear existing timeout for this printer
  1477. if (cameraUrlSaveTimeoutRef.current[printerId]) {
  1478. clearTimeout(cameraUrlSaveTimeoutRef.current[printerId]);
  1479. }
  1480. // Debounce the save (800ms delay)
  1481. cameraUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {
  1482. updatePrinterMutation.mutate({
  1483. id: printerId,
  1484. data: { external_camera_url: url || null }
  1485. });
  1486. }, 800);
  1487. };
  1488. const handleSnapshotUrlChange = (printerId: number, url: string) => {
  1489. setLocalSnapshotUrls(prev => ({ ...prev, [printerId]: url }));
  1490. if (snapshotUrlSaveTimeoutRef.current[printerId]) {
  1491. clearTimeout(snapshotUrlSaveTimeoutRef.current[printerId]);
  1492. }
  1493. snapshotUrlSaveTimeoutRef.current[printerId] = setTimeout(() => {
  1494. updatePrinterMutation.mutate({
  1495. id: printerId,
  1496. data: { external_camera_snapshot_url: url || null }
  1497. });
  1498. }, 800);
  1499. };
  1500. const handleUpdatePrinterCamera = (printerId: number, updates: { type?: string; enabled?: boolean; rotation?: number; lightAuto?: boolean }) => {
  1501. const data: Partial<{ external_camera_type: string | null; external_camera_enabled: boolean; camera_rotation: number; camera_light_auto: boolean }> = {};
  1502. if (updates.type !== undefined) data.external_camera_type = updates.type || null;
  1503. if (updates.enabled !== undefined) data.external_camera_enabled = updates.enabled;
  1504. if (updates.rotation !== undefined) data.camera_rotation = updates.rotation;
  1505. if (updates.lightAuto !== undefined) data.camera_light_auto = updates.lightAuto;
  1506. updatePrinterMutation.mutate({ id: printerId, data });
  1507. };
  1508. if (isLoading || !localSettings) {
  1509. return (
  1510. <div className="p-4 md:p-8 flex justify-center">
  1511. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1512. </div>
  1513. );
  1514. }
  1515. // Cross-tab search is powered by the module-level registry in lib/settingsSearch.
  1516. // Resolve i18n labels here so language changes take effect without re-registering.
  1517. const searchIndex = getSettingsSearchEntries().map(e => ({
  1518. ...e,
  1519. label: t(e.labelKey, e.labelFallback ?? e.labelKey),
  1520. }));
  1521. const searchQuery = settingsSearch.trim().toLowerCase();
  1522. const searchResults = searchQuery
  1523. ? searchIndex.filter(
  1524. e =>
  1525. e.label.toLowerCase().includes(searchQuery) ||
  1526. e.keywords.toLowerCase().includes(searchQuery)
  1527. ).slice(0, 8)
  1528. : [];
  1529. const jumpToSetting = (entry: typeof searchIndex[number]) => {
  1530. handleTabChange(entry.tab as TabType);
  1531. if (entry.subTab) {
  1532. selectUsersSubTab(entry.subTab as UsersSubTab);
  1533. }
  1534. setSettingsSearch('');
  1535. // Scroll to the card after the tab has rendered
  1536. setTimeout(() => {
  1537. const el = document.getElementById(entry.anchor);
  1538. if (el) {
  1539. el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  1540. el.classList.add('ring-2', 'ring-bambu-green');
  1541. setTimeout(() => el.classList.remove('ring-2', 'ring-bambu-green'), 1500);
  1542. }
  1543. }, 50);
  1544. };
  1545. // #2664 (reporter pchulpjoost): `docker compose pull` only works from the
  1546. // directory holding the compose file, so the bare command the update box
  1547. // printed could not be pasted anywhere useful. The saved setting wins; when
  1548. // it is blank we fall back to whatever the backend could infer, and when
  1549. // that is blank too we print the command without a `cd` rather than a made-up
  1550. // path that would fail on paste.
  1551. const composeDir =
  1552. (localSettings?.docker_compose_dir ?? '').trim() || (updateCheck?.compose_dir_detected ?? '');
  1553. // Quoted only when it has to be. The backend restricts this field to path
  1554. // characters, so quotes, $ and backticks cannot be in it and double-quoting
  1555. // a path with a space is safe.
  1556. const composeUpdateCommand = composeDir
  1557. ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
  1558. : 'docker compose pull && docker compose up -d';
  1559. const cameraLightMode = localSettings.camera_light_mode ?? 'off';
  1560. const lightPrinters = (printers ?? []).filter(p => p.camera_light_auto);
  1561. const externalCameraPrinters = (printers ?? []).filter(p => p.external_camera_enabled);
  1562. const countBadge = (count: number) => count > 0 && (
  1563. <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">{count}</span>
  1564. );
  1565. const statusDot = (on: boolean) => (
  1566. <span className={`w-2 h-2 rounded-full shrink-0 ${on ? 'bg-green-400' : 'bg-gray-500'}`} />
  1567. );
  1568. const haSensorCount = (haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0);
  1569. // General stays first, where most visits start; the rest are sorted by their
  1570. // label in the current language so each language reads alphabetically.
  1571. const generalTab: SettingsTabEntry = { tab: 'general', icon: SettingsIcon, label: t('settings.tabs.general') };
  1572. const otherTabs = ([
  1573. { tab: 'plugs', icon: Plug, label: t('settings.tabs.smartPlugs'), extra: countBadge(smartPlugs?.length ?? 0) },
  1574. { tab: 'sensors', icon: Gauge, label: t('settings.tabs.sensors'), extra: countBadge(haSensorCount) },
  1575. { tab: 'notifications', icon: Bell, label: t('settings.tabs.notifications'), extra: countBadge(notificationProviders?.length ?? 0) },
  1576. { tab: 'queue', icon: ListOrdered, label: t('settings.tabs.queue', 'Workflow') },
  1577. { tab: 'filament', icon: Cylinder, label: t('settings.tabs.filament') },
  1578. { tab: 'camera', icon: Video, label: t('settings.tabs.camera') },
  1579. { tab: 'network', icon: Wifi, label: t('settings.tabs.network'), extra: statusDot(!!mqttStatus?.enabled) },
  1580. // API Keys holds API keys, webhooks, the API browser and Connected Apps;
  1581. // a user who may see none of them would land on an empty tab.
  1582. ...(canSeeApiKeysTab
  1583. ? [{ tab: 'apikeys', icon: Key, label: t('settings.tabs.apiKeys'), extra: countBadge(apiKeys?.length ?? 0) } as SettingsTabEntry]
  1584. : []),
  1585. { tab: 'virtual-printer', icon: Printer, label: t('settings.tabs.virtualPrinter'), extra: statusDot(!!virtualPrinterRunning) },
  1586. { tab: 'spoolbuddy', icon: Scale, label: t('settings.tabs.spoolbuddy'), extra: <>{countBadge(spoolbuddyDeviceCount)}{statusDot(!!spoolbuddyAnyOnline)}</> },
  1587. { tab: 'failure-detection', icon: ScanEye, label: t('settings.tabs.failureDetection'), extra: statusDot(!!obicoActive) },
  1588. { tab: 'users', icon: Users, label: t('settings.tabs.users'), extra: authEnabled && <span className="w-2 h-2 rounded-full shrink-0 bg-green-400" /> },
  1589. { tab: 'backup', icon: Database, label: t('settings.tabs.backup'), extra: statusDot(!!((cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled) || settings?.local_backup_enabled)) },
  1590. ] as SettingsTabEntry[]).sort((x, y) => x.label.localeCompare(y.label, i18n.language));
  1591. return (
  1592. <CardDensityProvider density="dense">
  1593. <div className="p-4 md:p-8">
  1594. <div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
  1595. <div>
  1596. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  1597. <SettingsIcon className="w-7 h-7 text-bambu-green" />
  1598. {t('settings.title')}
  1599. </h1>
  1600. <p className="text-bambu-gray mt-1">{t('settings.configureBambuddy')}</p>
  1601. </div>
  1602. {/* Cross-tab search */}
  1603. <div className="relative sm:w-72">
  1604. <Search className="w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
  1605. <input
  1606. type="text"
  1607. value={settingsSearch}
  1608. onChange={(e) => setSettingsSearch(e.target.value)}
  1609. placeholder={t('settings.searchPlaceholder', 'Search settings…')}
  1610. className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  1611. />
  1612. {settingsSearch && (
  1613. <button
  1614. onClick={() => setSettingsSearch('')}
  1615. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-bambu-gray hover:text-white"
  1616. aria-label="Clear"
  1617. >
  1618. <X className="w-3.5 h-3.5" />
  1619. </button>
  1620. )}
  1621. {searchResults.length > 0 && (
  1622. <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-30 overflow-hidden">
  1623. {searchResults.map((entry) => (
  1624. <button
  1625. key={entry.anchor}
  1626. onClick={() => jumpToSetting(entry)}
  1627. className="w-full px-3 py-2 text-left hover:bg-bambu-dark-tertiary transition-colors border-b border-bambu-dark-tertiary last:border-b-0"
  1628. >
  1629. <p className="text-sm text-white">{entry.label}</p>
  1630. <p className="text-xs text-bambu-gray">
  1631. {t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab === 'failure-detection' ? 'failureDetection' : entry.tab}`)}
  1632. {entry.subTab ? ` › ${t(`settings.tabs.${entry.subTab}`, entry.subTab)}` : ''}
  1633. </p>
  1634. </button>
  1635. ))}
  1636. </div>
  1637. )}
  1638. {searchQuery && searchResults.length === 0 && (
  1639. <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-30 p-3">
  1640. <p className="text-xs text-bambu-gray italic">{t('settings.noSearchResults', 'No matching settings.')}</p>
  1641. </div>
  1642. )}
  1643. </div>
  1644. </div>
  1645. {/* Tab Navigation + content: horizontal tabs on mobile, vertical rail on lg+ */}
  1646. <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
  1647. <nav className="flex flex-wrap gap-1 border-b border-bambu-dark-tertiary lg:flex-col lg:flex-nowrap lg:gap-0 lg:border-b-0 lg:border-r lg:w-60 lg:flex-shrink-0 lg:self-start lg:sticky lg:top-4">
  1648. {[generalTab, ...otherTabs].map(({ tab, icon: Icon, label, extra }) => (
  1649. <button
  1650. key={tab}
  1651. onClick={() => handleTabChange(tab)}
  1652. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
  1653. activeTab === tab
  1654. ? 'text-bambu-green border-bambu-green'
  1655. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  1656. }`}
  1657. >
  1658. <Icon className="w-4 h-4 shrink-0" />
  1659. {label}
  1660. {extra}
  1661. </button>
  1662. ))}
  1663. </nav>
  1664. <div className="flex-1 min-w-0">
  1665. {activeTab === 'general' && (
  1666. <>
  1667. {/* Sponsor banner — independence callout, or the commercial ask on a
  1668. business-sized fleet (see utils/fleetAudience). */}
  1669. <a
  1670. href={sponsorHref(sponsorAudience, 'app-settings')}
  1671. target="_blank"
  1672. rel="noopener noreferrer"
  1673. className="group block mb-4 lg:mb-6 rounded-xl border border-bambu-green/30 bg-gradient-to-br from-bambu-green/15 via-bambu-green/5 to-transparent hover:border-bambu-green/50 hover:from-bambu-green/20 transition-colors"
  1674. >
  1675. <div className="flex flex-col md:flex-row items-start md:items-center gap-4 p-4 md:p-5">
  1676. <div className="p-3 rounded-lg bg-bambu-green/20 text-bambu-green flex-shrink-0">
  1677. {sponsorAudience === 'business' ? <Briefcase className="w-6 h-6" /> : <Heart className="w-6 h-6" />}
  1678. </div>
  1679. <div className="flex-1 min-w-0">
  1680. <p className="text-base font-semibold text-white">
  1681. {sponsorAudience === 'business'
  1682. ? t('sponsors.businessTitle', 'Bambuddy for business')
  1683. : t('sponsors.sectionTitle', 'Independent & community-funded')}
  1684. </p>
  1685. <p className="text-sm text-bambu-gray mt-0.5">
  1686. {sponsorAudience === 'business'
  1687. ? t(
  1688. 'sponsors.businessTagline',
  1689. "You're running {{count}} printers. Priority support, commercial licensing and invoicing are available for teams and print farms.",
  1690. { count: printers?.length ?? 0 }
  1691. )
  1692. : t(
  1693. 'sponsors.tagline',
  1694. 'Bambuddy is free and stays that way because people choose to support it. No VC, no cloud lock-in.'
  1695. )}
  1696. </p>
  1697. </div>
  1698. <div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-bambu-green/20 text-bambu-green group-hover:bg-bambu-green/30 text-sm font-medium whitespace-nowrap self-start md:self-auto">
  1699. {sponsorAudience === 'business'
  1700. ? t('sponsors.businessCta', 'Bambuddy for business')
  1701. : t('sponsors.viewSupporters', 'View supporters')}
  1702. <ExternalLink className="w-4 h-4" />
  1703. </div>
  1704. </div>
  1705. </a>
  1706. <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
  1707. {/* Left Column - General Settings */}
  1708. <div className="space-y-3 flex-1 lg:max-w-xl">
  1709. <Card id="card-general">
  1710. <CardHeader>
  1711. <h2 className="text-lg font-semibold text-white">{t('settings.general')}</h2>
  1712. </CardHeader>
  1713. <CardContent className="space-y-3">
  1714. <div>
  1715. <label className="block text-sm text-bambu-gray mb-1">
  1716. <Globe className="w-4 h-4 inline mr-1" />
  1717. {t('settings.language')}
  1718. </label>
  1719. <div className="relative">
  1720. <select
  1721. value={i18n.language}
  1722. onChange={(e) => {
  1723. const newLang = e.target.value;
  1724. // Block server persist if the user lacks settings:update —
  1725. // without this guard the fire-and-forget api.updateSettings
  1726. // call below would 403 silently while a success toast flashed.
  1727. if (authEnabled && !hasPermission('settings:update')) {
  1728. showToast(t('settings.toast.noPermissionUpdate'), 'error');
  1729. return;
  1730. }
  1731. i18n.changeLanguage(newLang);
  1732. updateMutation.mutate({ language: newLang });
  1733. }}
  1734. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  1735. >
  1736. {availableLanguages.map((lang) => (
  1737. <option key={lang.code} value={lang.code}>
  1738. {lang.nativeName} ({lang.name})
  1739. </option>
  1740. ))}
  1741. </select>
  1742. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1743. </div>
  1744. <p className="text-xs text-bambu-gray mt-1">
  1745. {t('settings.languageDescription')}
  1746. </p>
  1747. </div>
  1748. <div>
  1749. <label className="block text-sm text-bambu-gray mb-1">
  1750. {t('settings.defaultView')}
  1751. </label>
  1752. <div className="relative">
  1753. <select
  1754. value={defaultView}
  1755. onChange={(e) => handleDefaultViewChange(e.target.value)}
  1756. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  1757. >
  1758. {defaultNavItems.map((item) => (
  1759. <option key={item.id} value={item.to}>
  1760. {t(item.labelKey)}
  1761. </option>
  1762. ))}
  1763. </select>
  1764. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1765. </div>
  1766. <p className="text-xs text-bambu-gray mt-1">
  1767. {t('settings.defaultViewDescription')}
  1768. </p>
  1769. </div>
  1770. <div className="grid grid-cols-2 gap-3">
  1771. <div>
  1772. <label className="block text-sm text-bambu-gray mb-1">
  1773. {t('settings.dateFormat')}
  1774. </label>
  1775. <div className="relative">
  1776. <select
  1777. value={localSettings.date_format || 'system'}
  1778. onChange={(e) => updateSetting('date_format', e.target.value as 'system' | 'us' | 'eu' | 'iso')}
  1779. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  1780. >
  1781. <option value="system">{t('settings.systemDefault')}</option>
  1782. <option value="us">{t('settings.dateFormatUs')}</option>
  1783. <option value="eu">{t('settings.dateFormatEu')}</option>
  1784. <option value="iso">{t('settings.dateFormatIso')}</option>
  1785. </select>
  1786. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1787. </div>
  1788. </div>
  1789. <div>
  1790. <label className="block text-sm text-bambu-gray mb-1">
  1791. {t('settings.timeFormat')}
  1792. </label>
  1793. <div className="relative">
  1794. <select
  1795. value={localSettings.time_format || 'system'}
  1796. onChange={(e) => updateSetting('time_format', e.target.value as 'system' | '12h' | '24h')}
  1797. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  1798. >
  1799. <option value="system">{t('settings.systemDefault')}</option>
  1800. <option value="12h">{t('settings.timeFormat12')}</option>
  1801. <option value="24h">{t('settings.timeFormat24')}</option>
  1802. </select>
  1803. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1804. </div>
  1805. </div>
  1806. </div>
  1807. <div>
  1808. <label className="block text-sm text-bambu-gray mb-1">
  1809. {t('settings.defaultPrinter')}
  1810. </label>
  1811. <div className="relative">
  1812. <select
  1813. value={localSettings.default_printer_id ?? ''}
  1814. onChange={(e) => updateSetting('default_printer_id', e.target.value ? Number(e.target.value) : null)}
  1815. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  1816. >
  1817. <option value="">{t('settings.noDefaultPrinter')}</option>
  1818. {printers?.map((printer) => (
  1819. <option key={printer.id} value={printer.id}>
  1820. {printer.name}
  1821. </option>
  1822. ))}
  1823. </select>
  1824. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1825. </div>
  1826. <p className="text-xs text-bambu-gray mt-1">
  1827. {t('settings.defaultPrinterDescription')}
  1828. </p>
  1829. </div>
  1830. </CardContent>
  1831. </Card>
  1832. <Card id="card-appearance">
  1833. <CardHeader>
  1834. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1835. <Palette className="w-5 h-5" />
  1836. {t('settings.appearance')}
  1837. </h2>
  1838. </CardHeader>
  1839. <CardContent className="space-y-3">
  1840. {/* Theme Mode Selector */}
  1841. <div className="flex items-center gap-2 mb-2">
  1842. <label className="text-sm text-bambu-gray">{t('settings.theme')}:</label>
  1843. <div className="flex gap-1">
  1844. {([
  1845. { id: 'dark', label: t('settings.themeDark') },
  1846. { id: 'light', label: t('settings.themeLight') },
  1847. { id: 'system', label: t('settings.themeSystem') },
  1848. ] as const).map(({ id, label }) => (
  1849. <button
  1850. key={id}
  1851. onClick={() => { setMode(id); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1852. className={`px-3 py-1 text-xs rounded-lg border transition-colors ${mode === id ? 'border-bambu-green bg-bambu-green/10 text-bambu-green' : 'border-gray-300 dark:border-bambu-dark-tertiary text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white cursor-pointer'}`}
  1853. >
  1854. {label}
  1855. </button>
  1856. ))}
  1857. </div>
  1858. </div>
  1859. {/* Dark Mode Settings */}
  1860. <div className={`space-y-3 p-4 rounded-lg border ${resolvedMode === 'dark' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
  1861. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  1862. {t('settings.darkMode')}
  1863. {resolvedMode === 'dark' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
  1864. </h3>
  1865. <div className="grid grid-cols-3 gap-3">
  1866. <div>
  1867. <label className="block text-xs text-bambu-gray mb-1">{t('settings.background')}</label>
  1868. <select
  1869. value={darkBackground}
  1870. onChange={(e) => { setDarkBackground(e.target.value as DarkBackground); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1871. 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"
  1872. >
  1873. <option value="neutral">{t('settings.bgNeutral')}</option>
  1874. <option value="warm">{t('settings.bgWarm')}</option>
  1875. <option value="cool">{t('settings.bgCool')}</option>
  1876. <option value="oled">{t('settings.bgOled')}</option>
  1877. <option value="slate">{t('settings.bgSlate')}</option>
  1878. <option value="forest">{t('settings.bgForest')}</option>
  1879. </select>
  1880. </div>
  1881. <div>
  1882. <label className="block text-xs text-bambu-gray mb-1">{t('settings.accent')}</label>
  1883. <select
  1884. value={darkAccent}
  1885. onChange={(e) => { setDarkAccent(e.target.value as ThemeAccent); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1886. 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"
  1887. >
  1888. <option value="green">{t('settings.accentGreen')}</option>
  1889. <option value="teal">{t('settings.accentTeal')}</option>
  1890. <option value="blue">{t('settings.accentBlue')}</option>
  1891. <option value="orange">{t('settings.accentOrange')}</option>
  1892. <option value="purple">{t('settings.accentPurple')}</option>
  1893. <option value="red">{t('settings.accentRed')}</option>
  1894. </select>
  1895. </div>
  1896. <div>
  1897. <label className="block text-xs text-bambu-gray mb-1">{t('settings.style')}</label>
  1898. <select
  1899. value={darkStyle}
  1900. onChange={(e) => { setDarkStyle(e.target.value as ThemeStyle); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1901. 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"
  1902. >
  1903. <option value="classic">{t('settings.styleClassic')}</option>
  1904. <option value="glow">{t('settings.styleGlow')}</option>
  1905. <option value="vibrant">{t('settings.styleVibrant')}</option>
  1906. </select>
  1907. </div>
  1908. </div>
  1909. </div>
  1910. {/* Light Mode Settings */}
  1911. <div className={`space-y-3 p-4 rounded-lg border ${resolvedMode === 'light' ? 'border-bambu-green bg-bambu-green/5' : 'border-bambu-dark-tertiary'}`}>
  1912. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  1913. {t('settings.lightMode')}
  1914. {resolvedMode === 'light' && <span className="text-xs text-bambu-green">{t('settings.active')}</span>}
  1915. </h3>
  1916. <div className="grid grid-cols-3 gap-3">
  1917. <div>
  1918. <label className="block text-xs text-bambu-gray mb-1">{t('settings.background')}</label>
  1919. <select
  1920. value={lightBackground}
  1921. onChange={(e) => { setLightBackground(e.target.value as LightBackground); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1922. 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"
  1923. >
  1924. <option value="neutral">{t('settings.bgNeutral')}</option>
  1925. <option value="warm">{t('settings.bgWarm')}</option>
  1926. <option value="cool">{t('settings.bgCool')}</option>
  1927. </select>
  1928. </div>
  1929. <div>
  1930. <label className="block text-xs text-bambu-gray mb-1">{t('settings.accent')}</label>
  1931. <select
  1932. value={lightAccent}
  1933. onChange={(e) => { setLightAccent(e.target.value as ThemeAccent); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1934. 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"
  1935. >
  1936. <option value="green">{t('settings.accentGreen')}</option>
  1937. <option value="teal">{t('settings.accentTeal')}</option>
  1938. <option value="blue">{t('settings.accentBlue')}</option>
  1939. <option value="orange">{t('settings.accentOrange')}</option>
  1940. <option value="purple">{t('settings.accentPurple')}</option>
  1941. <option value="red">{t('settings.accentRed')}</option>
  1942. </select>
  1943. </div>
  1944. <div>
  1945. <label className="block text-xs text-bambu-gray mb-1">{t('settings.style')}</label>
  1946. <select
  1947. value={lightStyle}
  1948. onChange={(e) => { setLightStyle(e.target.value as ThemeStyle); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1949. 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"
  1950. >
  1951. <option value="classic">{t('settings.styleClassic')}</option>
  1952. <option value="glow">{t('settings.styleGlow')}</option>
  1953. <option value="vibrant">{t('settings.styleVibrant')}</option>
  1954. </select>
  1955. </div>
  1956. </div>
  1957. </div>
  1958. <p className="text-xs text-bambu-gray">
  1959. {t('settings.themeToggleHint')}
  1960. </p>
  1961. <div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary">
  1962. <div>
  1963. <p className="text-white">{t('settings.progressInTitle')}</p>
  1964. <p className="text-sm text-bambu-gray">
  1965. {t('settings.progressInTitleDescription')}
  1966. </p>
  1967. </div>
  1968. <label className="relative inline-flex items-center cursor-pointer">
  1969. <input
  1970. type="checkbox"
  1971. checked={progressInTitle}
  1972. onChange={(e) => { setProgressInTitle(e.target.checked); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1973. className="sr-only peer"
  1974. />
  1975. <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>
  1976. </label>
  1977. </div>
  1978. {progressInTitle && (
  1979. <div className="flex flex-wrap items-center gap-2">
  1980. <span className="text-sm text-bambu-gray">{t('settings.tabDisplay')}:</span>
  1981. <div className="flex gap-1">
  1982. {([
  1983. { id: 'percentage', label: t('settings.tabDisplayPercentage') },
  1984. { id: 'time', label: t('settings.tabDisplayTime') },
  1985. ] as const).map(({ id, label }) => (
  1986. <button
  1987. key={id}
  1988. type="button"
  1989. aria-pressed={tabDisplay === id}
  1990. onClick={() => { setTabDisplay(id); showToast(t('settings.toast.settingsSaved'), 'success'); }}
  1991. className={`px-3 py-1 text-xs rounded-lg border transition-colors ${tabDisplay === id ? 'border-bambu-green bg-bambu-green/10 text-bambu-green' : 'border-gray-300 dark:border-bambu-dark-tertiary text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white cursor-pointer'}`}
  1992. >
  1993. {label}
  1994. </button>
  1995. ))}
  1996. </div>
  1997. </div>
  1998. )}
  1999. </CardContent>
  2000. </Card>
  2001. <Card id="card-archive">
  2002. <CardHeader>
  2003. <h2 className="text-lg font-semibold text-white">{t('settings.archiveSettings')}</h2>
  2004. </CardHeader>
  2005. <CardContent className="space-y-3">
  2006. <div className="flex items-center justify-between">
  2007. <div>
  2008. <p className="text-white">{t('settings.autoArchivePrints')}</p>
  2009. <p className="text-sm text-bambu-gray">
  2010. {t('settings.autoArchiveDescription')}
  2011. </p>
  2012. </div>
  2013. <label className="relative inline-flex items-center cursor-pointer">
  2014. <input
  2015. type="checkbox"
  2016. checked={localSettings.auto_archive}
  2017. onChange={(e) => updateSetting('auto_archive', e.target.checked)}
  2018. className="sr-only peer"
  2019. />
  2020. <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>
  2021. </label>
  2022. </div>
  2023. <div className="flex items-center justify-between">
  2024. <div>
  2025. <p className="text-white">{t('settings.captureFinishPhoto')}</p>
  2026. <p className="text-sm text-bambu-gray">
  2027. {t('settings.captureFinishPhotoDescription')}
  2028. </p>
  2029. </div>
  2030. <label className="relative inline-flex items-center cursor-pointer">
  2031. <input
  2032. type="checkbox"
  2033. checked={localSettings.capture_finish_photo}
  2034. onChange={(e) => updateSetting('capture_finish_photo', e.target.checked)}
  2035. className="sr-only peer"
  2036. />
  2037. <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>
  2038. </label>
  2039. </div>
  2040. {/* #2547: only meaningful while finish photos are being taken at
  2041. all, so it hangs off the toggle above rather than standing
  2042. alone in the list. */}
  2043. {localSettings.capture_finish_photo && (
  2044. <div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
  2045. <div>
  2046. <p className="text-white">{t('settings.finishPhotoRestorePlate')}</p>
  2047. <p className="text-sm text-bambu-gray">
  2048. {t('settings.finishPhotoRestorePlateDescription')}
  2049. </p>
  2050. </div>
  2051. <label className="relative inline-flex items-center cursor-pointer">
  2052. <input
  2053. type="checkbox"
  2054. checked={localSettings.finish_photo_restore_plate ?? true}
  2055. onChange={(e) => updateSetting('finish_photo_restore_plate', e.target.checked)}
  2056. className="sr-only peer"
  2057. />
  2058. <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>
  2059. </label>
  2060. </div>
  2061. )}
  2062. {localSettings.capture_finish_photo && ffmpegStatus && !ffmpegStatus.installed && (
  2063. <div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
  2064. <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
  2065. <div className="text-sm">
  2066. <p className="text-yellow-500 font-medium">{t('settings.ffmpegNotInstalled')}</p>
  2067. <p className="text-bambu-gray mt-1">
  2068. {t('settings.ffmpegRequired')}
  2069. </p>
  2070. </div>
  2071. </div>
  2072. )}
  2073. {/* Archive auto-purge (#1008 follow-up). Admin-only — gated on
  2074. archives:delete_all. Hard-deletes archives older than the
  2075. configured age threshold once per 24h. */}
  2076. {canPurgeArchives && archivePurgeSettings && (
  2077. <div className="border-t border-bambu-dark-tertiary pt-3 mt-3 space-y-3">
  2078. <div className="flex items-center justify-between">
  2079. <div>
  2080. <p className="text-white">{t('archiveAutoPurge.enableLabel')}</p>
  2081. <p className="text-sm text-bambu-gray">{t('archiveAutoPurge.enableDescription')}</p>
  2082. </div>
  2083. <label className="relative inline-flex items-center cursor-pointer">
  2084. <input
  2085. type="checkbox"
  2086. checked={archivePurgeSettings.enabled}
  2087. onChange={(e) => saveArchivePurgeSettings({ enabled: e.target.checked })}
  2088. className="sr-only peer"
  2089. />
  2090. <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>
  2091. </label>
  2092. </div>
  2093. <div>
  2094. <label className="block text-sm text-bambu-gray mb-1">
  2095. {t('archiveAutoPurge.ageLabel')}
  2096. </label>
  2097. <div className="flex items-center gap-2">
  2098. <input
  2099. type="number"
  2100. min={7}
  2101. max={3650}
  2102. disabled={!archivePurgeSettings.enabled}
  2103. value={archivePurgeSettings.days}
  2104. onChange={(e) =>
  2105. saveArchivePurgeSettings({
  2106. days: Math.max(7, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)),
  2107. })
  2108. }
  2109. 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 disabled:opacity-50"
  2110. />
  2111. <span className="text-bambu-gray">{t('archiveAutoPurge.days')}</span>
  2112. </div>
  2113. <p className="text-xs text-bambu-gray mt-1">
  2114. {t('archiveAutoPurge.ageDescription')}
  2115. </p>
  2116. </div>
  2117. <label className="flex items-start gap-2 cursor-pointer">
  2118. <input
  2119. type="checkbox"
  2120. disabled={!archivePurgeSettings.enabled}
  2121. checked={archivePurgeSettings.purge_stats}
  2122. onChange={(e) => saveArchivePurgeSettings({ purge_stats: e.target.checked })}
  2123. className="mt-0.5 shrink-0 disabled:opacity-50"
  2124. />
  2125. <span className="text-sm">
  2126. <span className="text-white block">{t('archiveAutoPurge.purgeStatsLabel')}</span>
  2127. <span className="text-xs text-bambu-gray block mt-0.5">
  2128. {t('archiveAutoPurge.purgeStatsDescription')}
  2129. </span>
  2130. </span>
  2131. </label>
  2132. </div>
  2133. )}
  2134. </CardContent>
  2135. </Card>
  2136. </div>
  2137. {/* Second Column - Cost, AMS & Spoolman */}
  2138. <div className="space-y-3 flex-1 lg:max-w-md">
  2139. <Card id="card-cost">
  2140. <CardHeader>
  2141. <h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>
  2142. </CardHeader>
  2143. <CardContent className="space-y-3">
  2144. <div>
  2145. <label className="block text-sm text-bambu-gray mb-1">{t('settings.currency')}</label>
  2146. <select
  2147. value={localSettings.currency}
  2148. onChange={(e) => updateSetting('currency', e.target.value)}
  2149. 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"
  2150. >
  2151. {SUPPORTED_CURRENCIES.map((c) => (
  2152. <option key={c.code} value={c.code}>{c.label}</option>
  2153. ))}
  2154. </select>
  2155. </div>
  2156. <div>
  2157. <label className="block text-sm text-bambu-gray mb-1">
  2158. {t('settings.defaultFilamentCost')}
  2159. </label>
  2160. <div className="relative">
  2161. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
  2162. {getCurrencySymbol(localSettings.currency)}
  2163. </span>
  2164. <NumberInput
  2165. step="0.01"
  2166. min={0}
  2167. value={localSettings.default_filament_cost}
  2168. onChange={(v) => updateSetting('default_filament_cost', v)}
  2169. integer={false}
  2170. fallback={0}
  2171. style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
  2172. className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  2173. />
  2174. </div>
  2175. </div>
  2176. <div>
  2177. <label className="block text-sm text-bambu-gray mb-1">
  2178. {t('settings.energyPriceSource')}
  2179. </label>
  2180. <select
  2181. value={localSettings.energy_price_source === 'homeassistant' ? 'homeassistant' : 'fixed'}
  2182. onChange={(e) => updateSetting('energy_price_source', e.target.value as 'fixed' | 'homeassistant')}
  2183. 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"
  2184. >
  2185. <option value="fixed">{t('settings.energyPriceSourceFixed')}</option>
  2186. <option value="homeassistant">{t('settings.energyPriceSourceHomeAssistant')}</option>
  2187. </select>
  2188. {localSettings.energy_price_source === 'homeassistant' && !localSettings.ha_enabled && (
  2189. <p className="text-xs text-yellow-500 mt-1">{t('settings.energyPriceHaNotConfigured')}</p>
  2190. )}
  2191. </div>
  2192. {localSettings.energy_price_source === 'homeassistant' && (
  2193. <div>
  2194. <label className="block text-sm text-bambu-gray mb-1">
  2195. {t('settings.energyPriceHaEntity')}
  2196. </label>
  2197. <input
  2198. type="text"
  2199. list="ha-price-entities"
  2200. value={localSettings.energy_price_ha_entity ?? ''}
  2201. onChange={(e) => updateSetting('energy_price_ha_entity', e.target.value.trim())}
  2202. placeholder="sensor.electricity_price"
  2203. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  2204. />
  2205. <datalist id="ha-price-entities">
  2206. {(haPriceEntities ?? []).map((e) => (
  2207. <option key={e.entity_id} value={e.entity_id}>
  2208. {`${e.friendly_name} (${e.state ?? '?'} ${e.unit_of_measurement ?? ''})`}
  2209. </option>
  2210. ))}
  2211. </datalist>
  2212. </div>
  2213. )}
  2214. <div>
  2215. <label className="block text-sm text-bambu-gray mb-1">
  2216. {localSettings.energy_price_source === 'homeassistant'
  2217. ? t('settings.energyPriceLastRead')
  2218. : t('settings.electricityCost')}
  2219. </label>
  2220. <div className="relative">
  2221. <span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray text-sm pointer-events-none">
  2222. {getCurrencySymbol(localSettings.currency)}
  2223. </span>
  2224. <NumberInput
  2225. step="0.001"
  2226. min={0}
  2227. value={localSettings.energy_cost_per_kwh}
  2228. onChange={(v) => updateSetting('energy_cost_per_kwh', v)}
  2229. integer={false}
  2230. fallback={0}
  2231. disabled={localSettings.energy_price_source === 'homeassistant'}
  2232. style={{ paddingLeft: `${Math.max(2, getCurrencySymbol(localSettings.currency).length * 0.6 + 1)}rem` }}
  2233. className="w-full pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60"
  2234. />
  2235. </div>
  2236. <p className="text-xs text-bambu-gray mt-1">
  2237. {localSettings.energy_price_source === 'homeassistant'
  2238. ? t('settings.energyPriceHaHint')
  2239. : t('settings.energyPriceFixedHint')}
  2240. </p>
  2241. </div>
  2242. <div>
  2243. <label className="block text-sm text-bambu-gray mb-1">
  2244. {t('settings.energyDisplayMode')}
  2245. </label>
  2246. <select
  2247. value={localSettings.energy_tracking_mode || 'total'}
  2248. onChange={(e) => updateSetting('energy_tracking_mode', e.target.value as 'print' | 'total')}
  2249. 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"
  2250. >
  2251. <option value="print">{t('settings.printsOnly')}</option>
  2252. <option value="total">{t('settings.totalConsumption')}</option>
  2253. </select>
  2254. <p className="text-xs text-bambu-gray mt-1">
  2255. {localSettings.energy_tracking_mode === 'print'
  2256. ? t('settings.energyModePrintDescription')
  2257. : t('settings.energyModeTotalDescription')}
  2258. </p>
  2259. </div>
  2260. <div>
  2261. <div className="flex items-center justify-between">
  2262. <div>
  2263. <p className="text-white">{t('settings.billingEnabled')}</p>
  2264. <p className="text-xs text-bambu-gray">{t('settings.billingEnabledDescription')}</p>
  2265. </div>
  2266. <label className="relative inline-flex items-center cursor-pointer">
  2267. <input
  2268. type="checkbox"
  2269. checked={localSettings.billing_enabled ?? false}
  2270. onChange={(e) => updateSetting('billing_enabled', e.target.checked)}
  2271. className="peer sr-only"
  2272. />
  2273. <div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
  2274. </label>
  2275. </div>
  2276. {localSettings.billing_enabled && (
  2277. <div className="mt-4 flex items-center justify-between gap-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark px-4 py-3">
  2278. <div className="flex-1">
  2279. <p className="text-white">
  2280. {t('settings.printerKillSwitch', 'Unauthorized print kill switch')}
  2281. </p>
  2282. <p className="text-xs text-bambu-gray mt-1">
  2283. {t(
  2284. 'settings.printerKillSwitchDescription',
  2285. 'Immediately stop prints that start without authorization.'
  2286. )}
  2287. </p>
  2288. </div>
  2289. <label className="relative inline-flex items-center cursor-pointer">
  2290. <input
  2291. type="checkbox"
  2292. checked={localSettings.printer_kill_switch_enabled ?? false}
  2293. onChange={(e) => updateSetting('printer_kill_switch_enabled', e.target.checked)}
  2294. className="peer sr-only"
  2295. />
  2296. <div className="w-11 h-6 bg-bambu-dark-tertiary rounded-full peer-checked:bg-bambu-green peer-checked:after:translate-x-5 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:rounded-full after:h-5 after:w-5 after:transition-all" />
  2297. </label>
  2298. </div>
  2299. )}
  2300. {localSettings.billing_enabled && (
  2301. <div className="mt-4 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark p-4">
  2302. <div className="mb-3">
  2303. <h4 className="text-base font-medium text-white flex items-center gap-2">
  2304. <Calendar className="w-4 h-4 text-bambu-green" />
  2305. {t('settings.financeBudgetReset', 'Finance Monthly Budget Reset')}
  2306. </h4>
  2307. </div>
  2308. <div className="flex gap-4">
  2309. <div className="flex-1">
  2310. <label className="block text-xs text-bambu-gray mb-1">
  2311. {t('settings.financeBudgetResetDay', 'Reset Day')}
  2312. </label>
  2313. <input
  2314. type="number"
  2315. min={1}
  2316. max={31}
  2317. value={localSettings.finance_budget_reset_day ?? 1}
  2318. onChange={(e) =>
  2319. updateSetting(
  2320. 'finance_budget_reset_day',
  2321. Math.max(1, Math.min(31, parseInt(e.target.value, 10) || 1))
  2322. )
  2323. }
  2324. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  2325. />
  2326. <p className="text-xs text-bambu-gray mt-1">
  2327. {t('settings.financeBudgetResetDayHelp', 'For short months, reset uses the last day of the month.')}
  2328. </p>
  2329. </div>
  2330. <div className="flex-1">
  2331. <label className="block text-xs text-bambu-gray mb-1">
  2332. {t('settings.financeBudgetResetTimezone', 'Reset timezone')}
  2333. </label>
  2334. <select
  2335. value={localSettings.finance_budget_reset_timezone ?? 'UTC'}
  2336. onChange={(e) => updateSetting('finance_budget_reset_timezone', e.target.value)}
  2337. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  2338. >
  2339. <option value="UTC">UTC</option>
  2340. <option value="Europe/Berlin">Europe/Berlin</option>
  2341. <option value="Europe/Vienna">Europe/Vienna</option>
  2342. <option value="Europe/Zurich">Europe/Zurich</option>
  2343. <option value="America/New_York">America/New_York</option>
  2344. <option value="America/Chicago">America/Chicago</option>
  2345. <option value="America/Denver">America/Denver</option>
  2346. <option value="America/Los_Angeles">America/Los_Angeles</option>
  2347. <option value="Asia/Tokyo">Asia/Tokyo</option>
  2348. <option value="Asia/Singapore">Asia/Singapore</option>
  2349. <option value="Australia/Sydney">Australia/Sydney</option>
  2350. </select>
  2351. <p className="text-xs text-bambu-gray mt-1">
  2352. {t('settings.financeBudgetResetTimezoneHelp', 'Budget window start is calculated in this timezone.')}
  2353. </p>
  2354. </div>
  2355. </div>
  2356. </div>
  2357. )}
  2358. {localSettings.billing_enabled && isAdmin && (
  2359. <div className="mt-3">
  2360. <Button
  2361. variant="secondary"
  2362. onClick={() => setShowRebuildConfirm(true)}
  2363. disabled={isRebuildLoading}
  2364. >
  2365. {isRebuildLoading ? (
  2366. <>
  2367. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  2368. {t('settings.rebuildLedgerInProgress', 'Starting...')}
  2369. </>
  2370. ) : (
  2371. t('settings.rebuildLedger', 'Rebuild wallet ledger')
  2372. )}
  2373. </Button>
  2374. {showRebuildConfirm && (
  2375. <ConfirmModal
  2376. title={t('settings.rebuildLedgerConfirmTitle', 'Rebuild wallet ledger?')}
  2377. message={t(
  2378. 'settings.rebuildLedgerConfirmMessage',
  2379. 'This will rebuild the wallet ledger to repair historical balance values. Run this only if you know what you are doing.'
  2380. )}
  2381. confirmText={t('common.run', 'Run')}
  2382. cancelText={t('common.cancel', 'Cancel')}
  2383. isLoading={isRebuildLoading}
  2384. loadingText={t('common.running', 'Running')}
  2385. variant="warning"
  2386. onCancel={() => setShowRebuildConfirm(false)}
  2387. onConfirm={async () => {
  2388. setIsRebuildLoading(true);
  2389. try {
  2390. await api.rebuildBalanceLedger();
  2391. showToast(t('settings.rebuildLedgerStarted', 'Ledger rebuild started'), 'success');
  2392. setShowRebuildConfirm(false);
  2393. } catch (err) {
  2394. showToast((err as Error).message || 'Failed to start ledger rebuild', 'error');
  2395. } finally {
  2396. setIsRebuildLoading(false);
  2397. }
  2398. }}
  2399. />
  2400. )}
  2401. </div>
  2402. )}
  2403. </div>
  2404. </CardContent>
  2405. </Card>
  2406. {/* File Manager Settings */}
  2407. <Card id="card-filemanager">
  2408. <CardHeader>
  2409. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2410. <FileText className="w-5 h-5 text-bambu-green" />
  2411. {t('settings.fileManager')}
  2412. </h2>
  2413. </CardHeader>
  2414. <CardContent className="space-y-3">
  2415. {/* Disk Space Warning Threshold */}
  2416. <div>
  2417. <label className="block text-sm text-bambu-gray mb-1">
  2418. {t('settings.lowDiskSpaceWarning')}
  2419. </label>
  2420. <div className="flex items-center gap-2">
  2421. <NumberInput
  2422. min={0.5}
  2423. max={100}
  2424. step="0.5"
  2425. value={localSettings.library_disk_warning_gb ?? 5}
  2426. onChange={(v) => updateSetting('library_disk_warning_gb', v)}
  2427. integer={false}
  2428. fallback={5}
  2429. 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"
  2430. />
  2431. <span className="text-bambu-gray">GB</span>
  2432. </div>
  2433. <p className="text-xs text-bambu-gray mt-1">
  2434. {t('settings.lowDiskSpaceDescription')}
  2435. </p>
  2436. </div>
  2437. {/* Auto-purge (#1008). Admin-only — users without library:purge
  2438. don't see this section since they can't trigger a bulk purge
  2439. even manually. */}
  2440. {canPurge && trashSettings && (
  2441. <div className="border-t border-bambu-dark-tertiary pt-3 mt-3 space-y-3">
  2442. <div className="flex items-center justify-between">
  2443. <div>
  2444. <p className="text-white">{t('libraryAutoPurge.enableLabel')}</p>
  2445. <p className="text-sm text-bambu-gray">{t('libraryAutoPurge.enableDescription')}</p>
  2446. </div>
  2447. <label className="relative inline-flex items-center cursor-pointer">
  2448. <input
  2449. type="checkbox"
  2450. checked={trashSettings.auto_purge_enabled}
  2451. onChange={(e) => saveTrashSettings({ auto_purge_enabled: e.target.checked })}
  2452. className="sr-only peer"
  2453. />
  2454. <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>
  2455. </label>
  2456. </div>
  2457. <div>
  2458. <label className="block text-sm text-bambu-gray mb-1">
  2459. {t('libraryAutoPurge.ageLabel')}
  2460. </label>
  2461. <div className="flex items-center gap-2">
  2462. <input
  2463. type="number"
  2464. min={7}
  2465. max={3650}
  2466. disabled={!trashSettings.auto_purge_enabled}
  2467. value={trashSettings.auto_purge_days}
  2468. onChange={(e) =>
  2469. saveTrashSettings({
  2470. auto_purge_days: Math.max(7, Math.min(3650, parseInt(e.target.value || '0', 10) || 0)),
  2471. })
  2472. }
  2473. 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 disabled:opacity-50"
  2474. />
  2475. <span className="text-bambu-gray">{t('libraryAutoPurge.days')}</span>
  2476. </div>
  2477. <p className="text-xs text-bambu-gray mt-1">
  2478. {t('libraryAutoPurge.ageDescription')}
  2479. </p>
  2480. </div>
  2481. <label className="flex items-center gap-2 text-sm text-white cursor-pointer">
  2482. <input
  2483. type="checkbox"
  2484. disabled={!trashSettings.auto_purge_enabled}
  2485. checked={trashSettings.auto_purge_include_never_printed}
  2486. onChange={(e) => saveTrashSettings({ auto_purge_include_never_printed: e.target.checked })}
  2487. className="rounded border-gray-300 disabled:opacity-50"
  2488. />
  2489. {t('libraryAutoPurge.includeNeverPrinted')}
  2490. </label>
  2491. </div>
  2492. )}
  2493. </CardContent>
  2494. </Card>
  2495. {/* Data Management */}
  2496. <Card id="card-data">
  2497. <CardHeader>
  2498. <h2 className="text-lg font-semibold text-white">{t('settings.dataManagement')}</h2>
  2499. </CardHeader>
  2500. <CardContent className="space-y-3">
  2501. <div className="flex items-center justify-between">
  2502. <div>
  2503. <p className="text-white">{t('settings.clearNotificationLogs')}</p>
  2504. <p className="text-sm text-bambu-gray">
  2505. {t('settings.clearNotificationLogsDescription')}
  2506. </p>
  2507. </div>
  2508. <Button
  2509. variant="secondary"
  2510. size="sm"
  2511. onClick={() => setShowClearLogsConfirm(true)}
  2512. >
  2513. <Trash2 className="w-4 h-4" />
  2514. {t('common.clear')}
  2515. </Button>
  2516. </div>
  2517. <div className="flex items-center justify-between">
  2518. <div>
  2519. <p className="text-white">{t('settings.resetUiPreferences')}</p>
  2520. <p className="text-sm text-bambu-gray">
  2521. {t('settings.resetUiPreferencesDescription')}
  2522. </p>
  2523. </div>
  2524. <Button
  2525. variant="secondary"
  2526. size="sm"
  2527. onClick={() => setShowClearStorageConfirm(true)}
  2528. >
  2529. <Trash2 className="w-4 h-4" />
  2530. {t('settings.reset')}
  2531. </Button>
  2532. </div>
  2533. <div className="pt-4 border-t border-bambu-dark-tertiary">
  2534. <div className="flex items-center justify-between">
  2535. <div>
  2536. <p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
  2537. <p className="text-sm text-bambu-gray">
  2538. {t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
  2539. </p>
  2540. </div>
  2541. <Button
  2542. variant="secondary"
  2543. size="sm"
  2544. onClick={handleStorageUsageRefresh}
  2545. disabled={storageUsageFetching || storageUsageRefreshing}
  2546. >
  2547. <RefreshCw
  2548. className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
  2549. />
  2550. {t('common.refresh', 'Refresh')}
  2551. </Button>
  2552. </div>
  2553. <div className="mt-3">
  2554. {storageUsageLoading ? (
  2555. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  2556. <Loader2 className="w-4 h-4 animate-spin" />
  2557. {t('common.loading', 'Loading')}
  2558. </div>
  2559. ) : storageUsage ? (
  2560. <>
  2561. <div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
  2562. {storageUsage.categories
  2563. .filter((category) => category.bytes > 0)
  2564. .map((category, index) => (
  2565. <div
  2566. key={category.key}
  2567. className={`${getStorageColor(category.key, index)} h-full`}
  2568. style={{ width: `${category.percent_of_total}%` }}
  2569. title={`${category.label}: ${category.formatted}`}
  2570. />
  2571. ))}
  2572. </div>
  2573. <div className="mt-3 flex flex-wrap gap-3">
  2574. {storageUsage.categories
  2575. .filter((category) => category.bytes > 0)
  2576. .map((category, index) => (
  2577. <div key={category.key} className="flex items-center gap-2 text-xs">
  2578. <span
  2579. className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
  2580. />
  2581. <span className="text-bambu-gray">{category.label}</span>
  2582. <span className="text-white">{category.formatted}</span>
  2583. <span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
  2584. </div>
  2585. ))}
  2586. </div>
  2587. <div className="mt-2 text-xs text-bambu-gray">
  2588. {t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
  2589. {storageUsage.scan_errors > 0 && (
  2590. <span className="ml-2 text-amber-700 dark:text-amber-400">
  2591. {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
  2592. </span>
  2593. )}
  2594. </div>
  2595. {storageUsage.other_breakdown?.length > 0 && (
  2596. <div className="mt-4">
  2597. <p className="text-xs text-bambu-gray mb-2">
  2598. {t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
  2599. </p>
  2600. <div className="space-y-2">
  2601. {storageUsage.other_breakdown.map((item) => (
  2602. <div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
  2603. <div className="flex items-center gap-2">
  2604. <span className="text-white">{item.label}</span>
  2605. <span
  2606. className={`px-2 py-0.5 rounded-full border ${
  2607. item.kind === 'system'
  2608. ? 'border-slate-500 text-slate-300'
  2609. : 'border-bambu-green text-bambu-green'
  2610. }`}
  2611. >
  2612. {item.kind === 'system'
  2613. ? t('settings.storageUsageSystem', 'System')
  2614. : t('settings.storageUsageData', 'Data')}
  2615. </span>
  2616. </div>
  2617. <div className="flex items-center gap-2 text-bambu-gray">
  2618. <span className="text-white">{item.formatted}</span>
  2619. <span>({item.percent_of_total.toFixed(1)}%)</span>
  2620. </div>
  2621. </div>
  2622. ))}
  2623. </div>
  2624. </div>
  2625. )}
  2626. </>
  2627. ) : (
  2628. <p className="text-sm text-bambu-gray">
  2629. {t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
  2630. </p>
  2631. )}
  2632. </div>
  2633. </div>
  2634. <div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
  2635. <div>
  2636. <p className="text-white">{t('settings.backupRestore')}</p>
  2637. <p className="text-sm text-bambu-gray">
  2638. {t('settings.backupRestoreDescription')}
  2639. </p>
  2640. </div>
  2641. <Button
  2642. variant="secondary"
  2643. size="sm"
  2644. onClick={() => handleTabChange('backup')}
  2645. >
  2646. <Database className="w-4 h-4" />
  2647. {t('settings.goToBackup')}
  2648. </Button>
  2649. </div>
  2650. </CardContent>
  2651. </Card>
  2652. </div>
  2653. {/* Third Column - Updates & Sidebar Links */}
  2654. <div className="space-y-3 flex-1 lg:max-w-sm">
  2655. <Card id="card-updates">
  2656. <CardHeader>
  2657. <h2 className="text-lg font-semibold text-white">{t('settings.updates')}</h2>
  2658. </CardHeader>
  2659. <CardContent className="space-y-3">
  2660. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider">{t('settings.printerFirmware')}</p>
  2661. <div className="flex items-center justify-between">
  2662. <div>
  2663. <p className="text-white">{t('settings.checkPrinterFirmware')}</p>
  2664. <p className="text-sm text-bambu-gray">
  2665. {t('settings.checkFirmwareDescription')}
  2666. </p>
  2667. </div>
  2668. <label className="relative inline-flex items-center cursor-pointer">
  2669. <input
  2670. type="checkbox"
  2671. checked={localSettings.check_printer_firmware ?? true}
  2672. onChange={(e) => updateSetting('check_printer_firmware', e.target.checked)}
  2673. className="sr-only peer"
  2674. />
  2675. <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>
  2676. </label>
  2677. </div>
  2678. <div className="border-t border-bambu-dark-tertiary pt-4">
  2679. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4">{t('settings.bambuddySoftware')}</p>
  2680. </div>
  2681. <div className="flex items-center justify-between">
  2682. <div>
  2683. <p className="text-white">{t('settings.checkForUpdatesLabel')}</p>
  2684. <p className="text-sm text-bambu-gray">
  2685. {t('settings.autoCheckDescription')}
  2686. </p>
  2687. </div>
  2688. <label className="relative inline-flex items-center cursor-pointer">
  2689. <input
  2690. type="checkbox"
  2691. checked={localSettings.check_updates}
  2692. onChange={(e) => updateSetting('check_updates', e.target.checked)}
  2693. className="sr-only peer"
  2694. />
  2695. <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>
  2696. </label>
  2697. </div>
  2698. <div className={`flex items-center justify-between ${!localSettings.check_updates ? 'opacity-50' : ''}`}>
  2699. <div>
  2700. <p className="text-white">{t('settings.includeBetaUpdates')}</p>
  2701. <p className="text-sm text-bambu-gray">
  2702. {t('settings.includeBetaUpdatesDesc')}
  2703. </p>
  2704. </div>
  2705. <label className="relative inline-flex items-center cursor-pointer">
  2706. <input
  2707. type="checkbox"
  2708. checked={localSettings.include_beta_updates ?? false}
  2709. onChange={(e) => updateSetting('include_beta_updates', e.target.checked)}
  2710. disabled={!localSettings.check_updates}
  2711. className="sr-only peer"
  2712. />
  2713. <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>
  2714. </label>
  2715. </div>
  2716. <div>
  2717. <div className="flex items-center justify-between mb-2">
  2718. <div>
  2719. <p className="text-white">{t('settings.currentVersion')}</p>
  2720. <p className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</p>
  2721. </div>
  2722. <Button
  2723. variant="secondary"
  2724. size="sm"
  2725. onClick={() => refetchUpdateCheck()}
  2726. disabled={isCheckingUpdate}
  2727. >
  2728. {isCheckingUpdate ? (
  2729. <Loader2 className="w-4 h-4 animate-spin" />
  2730. ) : (
  2731. <RefreshCw className="w-4 h-4" />
  2732. )}
  2733. {t('settings.checkNow')}
  2734. </Button>
  2735. </div>
  2736. {updateCheck?.update_available ? (
  2737. <div className="mt-4 p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
  2738. <div className="flex items-start justify-between">
  2739. <div>
  2740. <p className="text-bambu-green font-medium">
  2741. Update available: v{updateCheck.latest_version}
  2742. </p>
  2743. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  2744. <p className="text-sm text-bambu-gray mt-1">{updateCheck.release_name}</p>
  2745. )}
  2746. </div>
  2747. <div className="flex items-center gap-2">
  2748. {updateCheck.release_notes && (
  2749. <button
  2750. onClick={() => setShowReleaseNotes(true)}
  2751. className="text-bambu-gray hover:text-white transition-colors text-sm underline"
  2752. >
  2753. {t('settings.releaseNotes')}
  2754. </button>
  2755. )}
  2756. {updateCheck.release_url && (
  2757. <a
  2758. href={updateCheck.release_url}
  2759. target="_blank"
  2760. rel="noopener noreferrer"
  2761. className="text-bambu-gray hover:text-white transition-colors"
  2762. title={t('settings.viewReleaseOnGitHub')}
  2763. >
  2764. <ExternalLink className="w-4 h-4" />
  2765. </a>
  2766. )}
  2767. </div>
  2768. </div>
  2769. {updateStatus?.status === 'downloading' || updateStatus?.status === 'installing' ? (
  2770. <div className="mt-3">
  2771. <div className="flex items-center gap-2 text-sm text-bambu-gray">
  2772. <Loader2 className="w-4 h-4 animate-spin" />
  2773. <span>{updateStatus.message}</span>
  2774. </div>
  2775. <div className="mt-2 w-full bg-bambu-dark-tertiary rounded-full h-2">
  2776. <div
  2777. className="bg-bambu-green h-2 rounded-full transition-all duration-300"
  2778. style={{ width: `${updateStatus.progress}%` }}
  2779. />
  2780. </div>
  2781. </div>
  2782. ) : updateStatus?.status === 'complete' ? (
  2783. <div className="mt-3 p-2 bg-bambu-green/20 rounded text-sm text-bambu-green">
  2784. {updateStatus.message}
  2785. </div>
  2786. ) : updateStatus?.status === 'error' ? (
  2787. <div className="mt-3 p-2 bg-red-100 dark:bg-red-500/20 rounded text-sm text-red-700 dark:text-red-400">
  2788. {updateStatus.error || updateStatus.message}
  2789. </div>
  2790. ) : updateCheck?.is_ha_addon ? (
  2791. <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
  2792. <p className="text-sm text-bambu-gray">
  2793. {t('settings.updateViaHomeAssistant')}
  2794. </p>
  2795. </div>
  2796. ) : updateCheck?.is_docker ? (
  2797. <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
  2798. <p className="text-sm text-bambu-gray mb-2">
  2799. {t('settings.updateViaDocker')}
  2800. </p>
  2801. <div className="flex items-start gap-1">
  2802. <code className="flex-1 block text-xs bg-bambu-dark p-2 rounded text-bambu-green font-mono break-all select-all">
  2803. {composeUpdateCommand}
  2804. </code>
  2805. <CopyButton
  2806. value={composeUpdateCommand}
  2807. titleKey="settings.copyUpdateCommand"
  2808. copiedTitleKey="printers.copied"
  2809. className="ml-0 p-2 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors flex-shrink-0"
  2810. iconClassName="w-4 h-4"
  2811. />
  2812. </div>
  2813. <label className="block mt-3">
  2814. <span className="block text-xs text-bambu-gray mb-1">
  2815. {t('settings.composeDirectory')}
  2816. </span>
  2817. <input
  2818. type="text"
  2819. value={localSettings?.docker_compose_dir ?? ''}
  2820. onChange={(e) => updateSetting('docker_compose_dir', e.target.value)}
  2821. placeholder={updateCheck?.compose_dir_detected || '/opt/bambuddy'}
  2822. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white font-mono placeholder:text-bambu-gray/60 focus:outline-none focus:border-bambu-green"
  2823. />
  2824. <span className="block text-xs text-bambu-gray mt-1">
  2825. {t('settings.composeDirectoryHint')}
  2826. </span>
  2827. </label>
  2828. </div>
  2829. ) : updateCheck?.update_method === 'windows_installer' ? (
  2830. <div className="mt-3 p-3 bg-bambu-dark-tertiary rounded-lg">
  2831. <p className="text-sm text-bambu-gray mb-3">
  2832. {t('settings.updateViaWindowsInstaller')}
  2833. </p>
  2834. <a
  2835. href={updateCheck.installer_download_url || updateCheck.release_url || `https://github.com/maziggy/bambuddy/releases/tag/v${updateCheck.latest_version}`}
  2836. target="_blank"
  2837. rel="noopener noreferrer"
  2838. className="inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0"
  2839. >
  2840. <Download className="w-4 h-4" />
  2841. {t('settings.downloadWindowsInstaller', { version: updateCheck.latest_version })}
  2842. </a>
  2843. </div>
  2844. ) : (
  2845. <Button
  2846. className="mt-3"
  2847. onClick={() => applyUpdateMutation.mutate()}
  2848. disabled={applyUpdateMutation.isPending}
  2849. >
  2850. {applyUpdateMutation.isPending ? (
  2851. <Loader2 className="w-4 h-4 animate-spin" />
  2852. ) : (
  2853. <Download className="w-4 h-4" />
  2854. )}
  2855. {t('settings.installUpdate')}
  2856. </Button>
  2857. )}
  2858. </div>
  2859. ) : updateCheck?.error ? (
  2860. <div className="mt-2 p-2 bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded text-sm text-red-700 dark:text-red-400">
  2861. {t('settings.failedToCheckUpdates', { error: updateCheck.error })}
  2862. </div>
  2863. ) : updateCheck && !updateCheck.update_available ? (
  2864. <p className="mt-2 text-sm text-bambu-gray">
  2865. {t('settings.latestVersionRunning')}
  2866. </p>
  2867. ) : null}
  2868. </div>
  2869. <div className="border-t border-bambu-dark-tertiary pt-4">
  2870. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4">{t('announcements.title')}</p>
  2871. </div>
  2872. <div className="flex items-center justify-between">
  2873. <div>
  2874. <p className="text-white">{t('settings.announcementsEnabled')}</p>
  2875. <p className="text-sm text-bambu-gray">
  2876. {t('settings.announcementsEnabledDesc')}{' '}
  2877. <a
  2878. href="https://wiki.bambuddy.cool/features/announcements/"
  2879. target="_blank"
  2880. rel="noopener noreferrer"
  2881. className="text-bambu-green hover:underline"
  2882. >
  2883. {t('settings.announcementsLearnMore')}
  2884. </a>
  2885. </p>
  2886. </div>
  2887. <label className="relative inline-flex items-center cursor-pointer">
  2888. <input
  2889. type="checkbox"
  2890. checked={localSettings.announcements_enabled ?? true}
  2891. onChange={(e) => updateSetting('announcements_enabled', e.target.checked)}
  2892. className="sr-only peer"
  2893. />
  2894. <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>
  2895. </label>
  2896. </div>
  2897. <div className={`flex items-center justify-between ${localSettings.announcements_enabled === false ? 'opacity-50' : ''}`}>
  2898. <div>
  2899. <p className="text-white">{t('settings.announcementsAllUsers')}</p>
  2900. <p className="text-sm text-bambu-gray">
  2901. {t('settings.announcementsAllUsersDesc')}
  2902. </p>
  2903. </div>
  2904. <label className="relative inline-flex items-center cursor-pointer">
  2905. <input
  2906. type="checkbox"
  2907. checked={localSettings.announcements_all_users ?? false}
  2908. onChange={(e) => updateSetting('announcements_all_users', e.target.checked)}
  2909. disabled={localSettings.announcements_enabled === false}
  2910. className="sr-only peer"
  2911. />
  2912. <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>
  2913. </label>
  2914. </div>
  2915. </CardContent>
  2916. </Card>
  2917. {/* Sidebar Links */}
  2918. <ExternalLinksSettings />
  2919. </div>
  2920. </div>
  2921. </>
  2922. )}
  2923. {/* Network Tab */}
  2924. {activeTab === 'network' && localSettings && (
  2925. <div className="flex flex-col lg:flex-row gap-6">
  2926. {/* Left Column - External URL & FTP Retry */}
  2927. <div className="flex-1 lg:max-w-xl space-y-3">
  2928. {/* External URL */}
  2929. <Card id="card-externalurl">
  2930. <CardHeader>
  2931. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2932. <Globe className="w-5 h-5 text-blue-600 dark:text-blue-400" />
  2933. {t('settings.externalUrl')}
  2934. </h2>
  2935. </CardHeader>
  2936. <CardContent className="space-y-3">
  2937. <p className="text-sm text-bambu-gray">
  2938. {t('settings.externalUrlDescription')}
  2939. </p>
  2940. <div>
  2941. <label className="block text-sm text-bambu-gray mb-1">
  2942. {t('settings.bambuddyUrl')}
  2943. </label>
  2944. <input
  2945. type="text"
  2946. value={localSettings.external_url ?? ''}
  2947. onChange={(e) => updateSetting('external_url', e.target.value)}
  2948. placeholder="http://192.168.1.100:8000"
  2949. disabled={!isAdmin}
  2950. title={!isAdmin ? t('settings.adminOnlySetting') : undefined}
  2951. 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 disabled:opacity-50"
  2952. />
  2953. {!isAdmin && <p className="text-xs text-bambu-gray mt-1">{t('settings.adminOnlySetting')}</p>}
  2954. <p className="text-xs text-bambu-gray mt-1">
  2955. {t('settings.externalUrlHint')}
  2956. </p>
  2957. </div>
  2958. </CardContent>
  2959. </Card>
  2960. <Card id="card-ftpretry">
  2961. <CardHeader>
  2962. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  2963. <RefreshCw className="w-5 h-5 text-blue-600 dark:text-blue-400" />
  2964. {t('settings.ftpRetry')}
  2965. </h2>
  2966. </CardHeader>
  2967. <CardContent className="space-y-3">
  2968. <p className="text-sm text-bambu-gray">
  2969. {t('settings.ftpRetryDescription')}
  2970. </p>
  2971. <div className="flex items-center justify-between">
  2972. <div>
  2973. <p className="text-white">{t('settings.enableRetry')}</p>
  2974. <p className="text-sm text-bambu-gray">
  2975. {t('settings.autoRetryDescription')}
  2976. </p>
  2977. </div>
  2978. <label className="relative inline-flex items-center cursor-pointer">
  2979. <input
  2980. type="checkbox"
  2981. checked={localSettings.ftp_retry_enabled ?? true}
  2982. onChange={(e) => updateSetting('ftp_retry_enabled', e.target.checked)}
  2983. className="sr-only peer"
  2984. />
  2985. <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>
  2986. </label>
  2987. </div>
  2988. {localSettings.ftp_retry_enabled && (
  2989. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  2990. <div>
  2991. <label className="block text-sm text-bambu-gray mb-1">
  2992. {t('settings.retryAttempts')}
  2993. </label>
  2994. <div className="relative w-44">
  2995. <select
  2996. value={localSettings.ftp_retry_count ?? 3}
  2997. onChange={(e) => updateSetting('ftp_retry_count', parseInt(e.target.value))}
  2998. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  2999. >
  3000. {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (
  3001. <option key={n} value={n}>{t('settings.time', { count: n })}</option>
  3002. ))}
  3003. </select>
  3004. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  3005. </div>
  3006. </div>
  3007. <div>
  3008. <label className="block text-sm text-bambu-gray mb-1">
  3009. {t('settings.retryDelay')}
  3010. </label>
  3011. <div className="relative w-44">
  3012. <select
  3013. value={localSettings.ftp_retry_delay ?? 2}
  3014. onChange={(e) => updateSetting('ftp_retry_delay', parseInt(e.target.value))}
  3015. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  3016. >
  3017. {[1, 2, 3, 5, 10, 15, 20, 30].map(n => (
  3018. <option key={n} value={n}>{t('settings.second', { count: n })}</option>
  3019. ))}
  3020. </select>
  3021. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  3022. </div>
  3023. </div>
  3024. <div>
  3025. <label className="block text-sm text-bambu-gray mb-1">
  3026. {t('settings.connectionTimeout')}
  3027. </label>
  3028. <div className="relative w-44">
  3029. <select
  3030. value={localSettings.ftp_timeout ?? 30}
  3031. onChange={(e) => updateSetting('ftp_timeout', parseInt(e.target.value))}
  3032. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  3033. >
  3034. {[10, 15, 20, 30, 45, 60, 90, 120, 180, 300].map(n => (
  3035. <option key={n} value={n}>{t('settings.nSeconds', { count: n })}</option>
  3036. ))}
  3037. </select>
  3038. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  3039. </div>
  3040. <p className="text-xs text-bambu-gray mt-1">
  3041. {t('settings.increaseForWeakWifi')}
  3042. </p>
  3043. </div>
  3044. </div>
  3045. )}
  3046. </CardContent>
  3047. </Card>
  3048. </div>
  3049. {/* Right Column - Home Assistant & MQTT Publishing */}
  3050. <div className="flex-1 lg:max-w-xl space-y-3">
  3051. {/* Home Assistant Integration */}
  3052. <Card id="card-ha">
  3053. <CardHeader>
  3054. <div className="flex items-center justify-between">
  3055. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  3056. <Home className="w-5 h-5 text-bambu-green" />
  3057. {t('settings.homeAssistant')}
  3058. </h2>
  3059. {localSettings.ha_enabled && haTestResult && (
  3060. <div className="flex items-center gap-2">
  3061. <span className={`w-2.5 h-2.5 rounded-full ${haTestResult.success ? 'bg-green-400' : 'bg-red-400'}`} />
  3062. <span className={`text-sm ${haTestResult.success ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}>
  3063. {haTestResult.success ? t('settings.connected') : t('settings.disconnected')}
  3064. </span>
  3065. </div>
  3066. )}
  3067. </div>
  3068. </CardHeader>
  3069. <CardContent className="space-y-3">
  3070. <p className="text-sm text-bambu-gray">
  3071. {t('settings.homeAssistantFullDescription')}
  3072. </p>
  3073. <div className="flex items-center justify-between">
  3074. <div className="flex-1">
  3075. <p className="text-white">{t('settings.enableHomeAssistant')}</p>
  3076. <p className="text-xs text-bambu-gray">{t('settings.homeAssistantDescription')}</p>
  3077. {localSettings.ha_env_managed && (
  3078. <div className="flex items-center gap-1 mt-1">
  3079. <Lock className="w-3 h-3 text-bambu-green" />
  3080. <span className="text-xs text-bambu-green">
  3081. {t('settings.autoEnabledViaEnv')}
  3082. </span>
  3083. </div>
  3084. )}
  3085. </div>
  3086. <label className="relative inline-flex items-center cursor-pointer">
  3087. <input
  3088. type="checkbox"
  3089. checked={localSettings.ha_enabled ?? false}
  3090. onChange={(e) => updateSetting('ha_enabled', e.target.checked)}
  3091. disabled={localSettings.ha_env_managed}
  3092. className="sr-only peer"
  3093. />
  3094. <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 ${
  3095. localSettings.ha_env_managed ? 'opacity-60 cursor-not-allowed' : ''
  3096. }`}></div>
  3097. </label>
  3098. </div>
  3099. {localSettings.ha_enabled && (
  3100. <>
  3101. <div>
  3102. <label className="block text-sm text-bambu-gray mb-1">
  3103. {t('settings.homeAssistantUrl')}
  3104. {localSettings.ha_url_from_env && (
  3105. <span className="ml-2 text-xs text-bambu-green">
  3106. {t('settings.environmentManagedLabel')}
  3107. </span>
  3108. )}
  3109. </label>
  3110. <div className="relative">
  3111. <input
  3112. type="text"
  3113. value={localSettings.ha_url ?? ''}
  3114. onChange={(e) => updateSetting('ha_url', e.target.value)}
  3115. placeholder="http://192.168.1.100:8123"
  3116. disabled={localSettings.ha_url_from_env}
  3117. 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 ${
  3118. localSettings.ha_url_from_env ? 'opacity-60 cursor-not-allowed' : ''
  3119. }`}
  3120. />
  3121. {localSettings.ha_url_from_env && (
  3122. <Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
  3123. )}
  3124. </div>
  3125. {localSettings.ha_url_from_env && (
  3126. <p className="text-xs text-bambu-gray mt-1">
  3127. {t('settings.urlFromEnvReadOnly')}
  3128. </p>
  3129. )}
  3130. </div>
  3131. <div>
  3132. <label className="block text-sm text-bambu-gray mb-1">
  3133. {t('settings.longLivedAccessToken')}
  3134. {localSettings.ha_token_from_env && (
  3135. <span className="ml-2 text-xs text-bambu-green">
  3136. {t('settings.environmentManagedLabel')}
  3137. </span>
  3138. )}
  3139. </label>
  3140. <div className="relative">
  3141. <input
  3142. type="password"
  3143. value={localSettings.ha_token ?? ''}
  3144. onChange={(e) => updateSetting('ha_token', e.target.value)}
  3145. onFocus={selectPlaceholderOnFocus}
  3146. placeholder="eyJ0eXAiOiJKV1QiLC..."
  3147. disabled={localSettings.ha_token_from_env}
  3148. 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 ${
  3149. localSettings.ha_token_from_env ? 'opacity-60 cursor-not-allowed' : ''
  3150. }`}
  3151. />
  3152. {localSettings.ha_token_from_env && (
  3153. <Lock className="absolute right-3 top-2.5 w-4 h-4 text-bambu-gray" />
  3154. )}
  3155. </div>
  3156. {!localSettings.ha_token_from_env && <SecretHint name="ha_token" value={localSettings.ha_token} reenter={secretsPendingReentry} />}
  3157. {localSettings.ha_token_from_env ? (
  3158. <p className="text-xs text-bambu-gray mt-1">
  3159. {t('settings.tokenFromEnvReadOnly')}
  3160. </p>
  3161. ) : (
  3162. <p className="text-xs text-bambu-gray mt-1">
  3163. {t('settings.haTokenHint')}
  3164. </p>
  3165. )}
  3166. </div>
  3167. {localSettings.ha_url && localSettings.ha_token && (
  3168. <div className="pt-2 border-t border-bambu-dark-tertiary">
  3169. <Button
  3170. variant="secondary"
  3171. size="sm"
  3172. disabled={haTestLoading}
  3173. onClick={async () => {
  3174. setHaTestLoading(true);
  3175. setHaTestResult(null);
  3176. try {
  3177. const result = await api.testHAConnection(localSettings.ha_url!, localSettings.ha_token!);
  3178. setHaTestResult(result);
  3179. } catch (e) {
  3180. setHaTestResult({ success: false, message: null, error: e instanceof Error ? e.message : t('common.unknownError') });
  3181. } finally {
  3182. setHaTestLoading(false);
  3183. }
  3184. }}
  3185. >
  3186. {haTestLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wifi className="w-4 h-4" />}
  3187. {t('settings.testConnection')}
  3188. </Button>
  3189. </div>
  3190. )}
  3191. </>
  3192. )}
  3193. </CardContent>
  3194. </Card>
  3195. {/* MQTT Publishing */}
  3196. <Card id="card-mqtt">
  3197. <CardHeader>
  3198. <div className="flex items-center justify-between">
  3199. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  3200. <Wifi className="w-5 h-5 text-blue-600 dark:text-blue-400" />
  3201. {t('settings.mqttPublishing')}
  3202. </h2>
  3203. {mqttStatus?.enabled && (
  3204. <div className="flex items-center gap-2">
  3205. <span className={`w-2.5 h-2.5 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />
  3206. <span className={`text-sm ${mqttStatus.connected ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}>
  3207. {mqttStatus.connected ? t('settings.connected') : t('settings.disconnected')}
  3208. </span>
  3209. </div>
  3210. )}
  3211. </div>
  3212. </CardHeader>
  3213. <CardContent className="space-y-3">
  3214. <p className="text-sm text-bambu-gray">
  3215. {t('settings.mqttDescription')}
  3216. </p>
  3217. <div className="flex items-center justify-between">
  3218. <div>
  3219. <p className="text-white">{t('settings.enableMqtt')}</p>
  3220. <p className="text-sm text-bambu-gray">
  3221. {t('settings.mqttEnableDescription')}
  3222. </p>
  3223. </div>
  3224. <label className="relative inline-flex items-center cursor-pointer">
  3225. <input
  3226. type="checkbox"
  3227. checked={localSettings.mqtt_enabled ?? false}
  3228. onChange={(e) => updateSetting('mqtt_enabled', e.target.checked)}
  3229. className="sr-only peer"
  3230. />
  3231. <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>
  3232. </label>
  3233. </div>
  3234. {localSettings.mqtt_enabled && (
  3235. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  3236. <div>
  3237. <label className="block text-sm text-bambu-gray mb-1">
  3238. {t('settings.brokerHostname')}
  3239. </label>
  3240. <input
  3241. type="text"
  3242. value={localSettings.mqtt_broker ?? ''}
  3243. onChange={(e) => updateSetting('mqtt_broker', e.target.value)}
  3244. placeholder="mqtt.example.com or 192.168.1.100"
  3245. 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"
  3246. />
  3247. </div>
  3248. <div className="flex items-end gap-4">
  3249. <div className="flex-1">
  3250. <label className="block text-sm text-bambu-gray mb-1">
  3251. {t('settings.port')}
  3252. </label>
  3253. <NumberInput
  3254. min={1}
  3255. max={65535}
  3256. value={localSettings.mqtt_port ?? 1883}
  3257. onChange={(v) => updateSetting('mqtt_port', v)}
  3258. fallback={1883}
  3259. 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"
  3260. />
  3261. </div>
  3262. <div className="flex items-center gap-3 pb-2">
  3263. <label className="relative inline-flex items-center cursor-pointer">
  3264. <input
  3265. type="checkbox"
  3266. checked={localSettings.mqtt_use_tls ?? false}
  3267. onChange={(e) => {
  3268. const useTls = e.target.checked;
  3269. updateSetting('mqtt_use_tls', useTls);
  3270. // Auto-populate port based on TLS selection
  3271. const currentPort = localSettings.mqtt_port ?? 1883;
  3272. if (useTls && currentPort === 1883) {
  3273. updateSetting('mqtt_port', 8883);
  3274. } else if (!useTls && currentPort === 8883) {
  3275. updateSetting('mqtt_port', 1883);
  3276. }
  3277. }}
  3278. className="sr-only peer"
  3279. />
  3280. <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>
  3281. </label>
  3282. <span className="text-white text-sm">{t('settings.useTls')}</span>
  3283. </div>
  3284. </div>
  3285. <div>
  3286. <label className="block text-sm text-bambu-gray mb-1">
  3287. {t('settings.usernameOptional')}
  3288. </label>
  3289. <input
  3290. type="text"
  3291. value={localSettings.mqtt_username ?? ''}
  3292. onChange={(e) => updateSetting('mqtt_username', e.target.value)}
  3293. placeholder={t('settings.leaveEmptyForAnonymous')}
  3294. 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"
  3295. />
  3296. </div>
  3297. <div>
  3298. <label className="block text-sm text-bambu-gray mb-1">
  3299. {t('settings.passwordOptional')}
  3300. </label>
  3301. <input
  3302. type="password"
  3303. value={localSettings.mqtt_password ?? ''}
  3304. onChange={(e) => updateSetting('mqtt_password', e.target.value)}
  3305. onFocus={selectPlaceholderOnFocus}
  3306. placeholder={t('settings.leaveEmptyForAnonymous')}
  3307. 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"
  3308. />
  3309. <SecretHint name="mqtt_password" value={localSettings.mqtt_password} reenter={secretsPendingReentry} />
  3310. </div>
  3311. <div>
  3312. <label className="block text-sm text-bambu-gray mb-1">
  3313. {t('settings.topicPrefix')}
  3314. </label>
  3315. <input
  3316. type="text"
  3317. value={localSettings.mqtt_topic_prefix ?? 'bambuddy'}
  3318. onChange={(e) => updateSetting('mqtt_topic_prefix', e.target.value)}
  3319. placeholder="bambuddy"
  3320. 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"
  3321. />
  3322. <p className="text-xs text-bambu-gray mt-1">
  3323. {t('settings.topicPrefixHint', { prefix: localSettings.mqtt_topic_prefix || 'bambuddy' })}
  3324. </p>
  3325. </div>
  3326. {/* Connection Info */}
  3327. {mqttStatus && (
  3328. <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary">
  3329. <div className="flex items-center gap-2 text-sm">
  3330. <span className={`w-2 h-2 rounded-full ${mqttStatus.connected ? 'bg-green-400' : 'bg-red-400'}`} />
  3331. <span className="text-bambu-gray">
  3332. {mqttStatus.connected ? (
  3333. <>{t('settings.mqttConnectedTo')} <span className="text-white">{mqttStatus.broker}:{mqttStatus.port}</span></>
  3334. ) : (
  3335. t('settings.spoolmanDisconnected')
  3336. )}
  3337. </span>
  3338. </div>
  3339. </div>
  3340. )}
  3341. </div>
  3342. )}
  3343. </CardContent>
  3344. </Card>
  3345. </div>
  3346. {/* Third Column - Prometheus Metrics */}
  3347. <div className="flex-1 lg:max-w-md space-y-3">
  3348. <Card id="card-prometheus">
  3349. <CardHeader>
  3350. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  3351. <TrendingUp className="w-5 h-5 text-orange-600 dark:text-orange-400" />
  3352. {t('settings.prometheusMetrics')}
  3353. </h2>
  3354. </CardHeader>
  3355. <CardContent className="space-y-3">
  3356. <p className="text-sm text-bambu-gray">
  3357. {t('settings.prometheusEndpointDescription')}
  3358. </p>
  3359. <div className="flex items-center justify-between">
  3360. <div>
  3361. <p className="text-white">{t('settings.enableMetricsEndpoint')}</p>
  3362. <p className="text-xs text-bambu-gray">{t('settings.prometheusDescription')}</p>
  3363. {!isAdmin && (
  3364. <p id="prometheus-admin-only" className="text-xs text-bambu-gray mt-1">
  3365. {t('settings.adminOnlySetting')}
  3366. </p>
  3367. )}
  3368. </div>
  3369. <label className="relative inline-flex items-center cursor-pointer">
  3370. <input
  3371. type="checkbox"
  3372. checked={localSettings.prometheus_enabled ?? false}
  3373. onChange={(e) => updateSetting('prometheus_enabled', e.target.checked)}
  3374. disabled={!isAdmin}
  3375. aria-describedby={!isAdmin ? 'prometheus-admin-only' : undefined}
  3376. className="sr-only peer"
  3377. />
  3378. <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>
  3379. </label>
  3380. </div>
  3381. {localSettings.prometheus_enabled && (
  3382. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  3383. <div>
  3384. <label className="block text-sm text-bambu-gray mb-1">
  3385. {t('settings.bearerTokenOptional')}
  3386. </label>
  3387. <input
  3388. type="password"
  3389. value={localSettings.prometheus_token ?? ''}
  3390. onChange={(e) => updateSetting('prometheus_token', e.target.value)}
  3391. disabled={!isAdmin}
  3392. onFocus={selectPlaceholderOnFocus}
  3393. placeholder={t('settings.leaveEmptyForNoAuth')}
  3394. 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"
  3395. />
  3396. <SecretHint name="prometheus_token" value={localSettings.prometheus_token} reenter={secretsPendingReentry} />
  3397. <p className="text-xs text-bambu-gray mt-1">
  3398. {t('settings.bearerTokenHint')}
  3399. </p>
  3400. </div>
  3401. <div className="pt-2 border-t border-bambu-dark-tertiary">
  3402. <p className="text-sm text-white mb-2">{t('settings.availableMetrics')}</p>
  3403. <div className="text-xs text-bambu-gray space-y-1">
  3404. <p><code className="text-orange-700 dark:text-orange-400">bambuddy_printer_connected</code> - {t('settings.metricsConnectionStatus')}</p>
  3405. <p><code className="text-orange-700 dark:text-orange-400">bambuddy_printer_state</code> - {t('settings.metricsPrinterState')}</p>
  3406. <p><code className="text-orange-700 dark:text-orange-400">bambuddy_print_progress</code> - {t('settings.metricsPrintProgress')}</p>
  3407. <p><code className="text-orange-700 dark:text-orange-400">bambuddy_bed_temp_celsius</code> - {t('settings.metricsBedTemp')}</p>
  3408. <p><code className="text-orange-700 dark:text-orange-400">bambuddy_nozzle_temp_celsius</code> - {t('settings.metricsNozzleTemp')}</p>
  3409. <p><code className="text-orange-700 dark:text-orange-400">bambuddy_prints_total</code> - {t('settings.metricsPrintsTotal')}</p>
  3410. <p className="text-bambu-gray/70 italic">{t('settings.metricsMore')}</p>
  3411. </div>
  3412. </div>
  3413. </div>
  3414. )}
  3415. </CardContent>
  3416. </Card>
  3417. </div>
  3418. </div>
  3419. )}
  3420. {/* Home Assistant Test Connection Modal */}
  3421. {haTestResult && (
  3422. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
  3423. <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full mx-4">
  3424. <div className="flex items-center gap-3 mb-4">
  3425. {haTestResult.success ? (
  3426. <CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
  3427. ) : (
  3428. <XCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
  3429. )}
  3430. <h3 className="text-lg font-medium text-white">
  3431. {haTestResult.success ? t('settings.connectionSuccessful') : t('settings.connectionFailed')}
  3432. </h3>
  3433. </div>
  3434. <p className="text-bambu-gray mb-6">
  3435. {haTestResult.success
  3436. ? haTestResult.message || t('settings.haConnectionSuccess')
  3437. : haTestResult.error || t('settings.haConnectionFailed')}
  3438. </p>
  3439. <div className="flex justify-end">
  3440. <Button
  3441. variant="primary"
  3442. onClick={() => setHaTestResult(null)}
  3443. >
  3444. {t('settings.ok')}
  3445. </Button>
  3446. </div>
  3447. </div>
  3448. </div>
  3449. )}
  3450. {/* Smart Plugs Tab */}
  3451. {activeTab === 'plugs' && (
  3452. <div id="card-plugs">
  3453. <div className="flex items-start justify-between mb-6">
  3454. <div>
  3455. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  3456. <Plug className="w-5 h-5 text-bambu-green" />
  3457. {t('settings.smartPlugs')}
  3458. </h2>
  3459. <p className="text-sm text-bambu-gray mt-1">
  3460. {t('settings.smartPlugsDescription')}
  3461. </p>
  3462. </div>
  3463. <div className="flex items-center gap-2 pt-1 shrink-0">
  3464. {smartPlugs && smartPlugs.filter(p => p.enabled).length > 1 && (
  3465. <>
  3466. <Button
  3467. variant="secondary"
  3468. size="sm"
  3469. className="whitespace-nowrap"
  3470. onClick={() => setShowBulkPlugConfirm('on')}
  3471. disabled={bulkPlugActionMutation.isPending}
  3472. title={t('settings.turnAllPlugsOn')}
  3473. >
  3474. {bulkPlugActionMutation.isPending ? (
  3475. <Loader2 className="w-4 h-4 animate-spin" />
  3476. ) : (
  3477. <Power className="w-4 h-4 text-bambu-green" />
  3478. )}
  3479. {t('settings.allOn')}
  3480. </Button>
  3481. <Button
  3482. variant="secondary"
  3483. size="sm"
  3484. className="whitespace-nowrap"
  3485. onClick={() => setShowBulkPlugConfirm('off')}
  3486. disabled={bulkPlugActionMutation.isPending}
  3487. title={t('settings.turnAllPlugsOff')}
  3488. >
  3489. {bulkPlugActionMutation.isPending ? (
  3490. <Loader2 className="w-4 h-4 animate-spin" />
  3491. ) : (
  3492. <PowerOff className="w-4 h-4 text-red-600 dark:text-red-400" />
  3493. )}
  3494. {t('settings.allOff')}
  3495. </Button>
  3496. </>
  3497. )}
  3498. <Button
  3499. className="whitespace-nowrap"
  3500. onClick={() => {
  3501. setEditingPlug(null);
  3502. setShowPlugModal(true);
  3503. }}
  3504. >
  3505. <Plus className="w-4 h-4" />
  3506. {t('settings.addSmartPlug')}
  3507. </Button>
  3508. </div>
  3509. </div>
  3510. {/* Energy Summary Card */}
  3511. {smartPlugs && smartPlugs.length > 0 && (
  3512. <Card className="mb-6">
  3513. <CardHeader>
  3514. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  3515. <Zap className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
  3516. {t('settings.energySummary')}
  3517. {energyLoading && (
  3518. <Loader2 className="w-4 h-4 animate-spin text-bambu-gray ml-2" />
  3519. )}
  3520. </h3>
  3521. </CardHeader>
  3522. <CardContent>
  3523. {plugEnergySummary ? (
  3524. <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
  3525. {/* Current Power */}
  3526. <div className="bg-bambu-dark rounded-lg p-3">
  3527. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  3528. <Zap className="w-3 h-3" />
  3529. {t('settings.currentPower')}
  3530. </div>
  3531. <div className="text-xl font-bold text-white">
  3532. {plugEnergySummary.totalPower.toFixed(1)}
  3533. <span className="text-sm font-normal text-bambu-gray ml-1">W</span>
  3534. </div>
  3535. <div className="text-xs text-bambu-gray mt-1">
  3536. {t('settings.plugsOnline', { reachable: plugEnergySummary.reachableCount, total: plugEnergySummary.totalPlugs })}
  3537. </div>
  3538. </div>
  3539. {/* Today */}
  3540. <div className="bg-bambu-dark rounded-lg p-3">
  3541. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  3542. <Calendar className="w-3 h-3" />
  3543. {t('settings.today')}
  3544. </div>
  3545. <div className="text-xl font-bold text-white">
  3546. {plugEnergySummary.totalToday.toFixed(3)}
  3547. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  3548. </div>
  3549. {(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
  3550. <div className="text-xs text-bambu-gray mt-1">
  3551. ~{(plugEnergySummary.totalToday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
  3552. </div>
  3553. )}
  3554. </div>
  3555. {/* Yesterday */}
  3556. <div className="bg-bambu-dark rounded-lg p-3">
  3557. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  3558. <TrendingUp className="w-3 h-3" />
  3559. {t('settings.yesterday')}
  3560. </div>
  3561. <div className="text-xl font-bold text-white">
  3562. {plugEnergySummary.totalYesterday.toFixed(3)}
  3563. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  3564. </div>
  3565. {(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
  3566. <div className="text-xs text-bambu-gray mt-1">
  3567. ~{(plugEnergySummary.totalYesterday * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
  3568. </div>
  3569. )}
  3570. </div>
  3571. {/* Total Lifetime */}
  3572. <div className="bg-bambu-dark rounded-lg p-3">
  3573. <div className="flex items-center gap-2 text-bambu-gray text-xs mb-1">
  3574. <DollarSign className="w-3 h-3" />
  3575. {t('settings.total')}
  3576. </div>
  3577. <div className="text-xl font-bold text-white">
  3578. {plugEnergySummary.totalLifetime.toFixed(1)}
  3579. <span className="text-sm font-normal text-bambu-gray ml-1">kWh</span>
  3580. </div>
  3581. {(localSettings?.energy_cost_per_kwh ?? 0) > 0 && (
  3582. <div className="text-xs text-bambu-gray mt-1">
  3583. ~{(plugEnergySummary.totalLifetime * (localSettings?.energy_cost_per_kwh ?? 0)).toFixed(2)} {getCurrencySymbol(localSettings?.currency || 'USD')}
  3584. </div>
  3585. )}
  3586. </div>
  3587. </div>
  3588. ) : !energyLoading ? (
  3589. <p className="text-sm text-bambu-gray">
  3590. {t('settings.enablePlugsForSummary')}
  3591. </p>
  3592. ) : null}
  3593. </CardContent>
  3594. </Card>
  3595. )}
  3596. {plugsLoading ? (
  3597. <div className="flex justify-center py-12">
  3598. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  3599. </div>
  3600. ) : smartPlugs && smartPlugs.length > 0 ? (
  3601. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
  3602. {smartPlugs.map((plug) => (
  3603. <SmartPlugCard
  3604. key={plug.id}
  3605. plug={plug}
  3606. onEdit={(p) => {
  3607. setEditingPlug(p);
  3608. setShowPlugModal(true);
  3609. }}
  3610. />
  3611. ))}
  3612. </div>
  3613. ) : (
  3614. <Card>
  3615. <CardContent className="py-12">
  3616. <div className="text-center text-bambu-gray">
  3617. <Plug className="w-16 h-16 mx-auto mb-4 opacity-30" />
  3618. <p className="text-lg font-medium text-white mb-2">{t('settings.noSmartPlugsTitle')}</p>
  3619. <p className="text-sm mb-4">{t('settings.noSmartPlugsDescription')}</p>
  3620. <Button
  3621. onClick={() => {
  3622. setEditingPlug(null);
  3623. setShowPlugModal(true);
  3624. }}
  3625. >
  3626. <Plus className="w-4 h-4" />
  3627. {t('settings.addFirstSmartPlug')}
  3628. </Button>
  3629. </div>
  3630. </CardContent>
  3631. </Card>
  3632. )}
  3633. </div>
  3634. )}
  3635. {activeTab === 'sensors' && (
  3636. <div id="card-sensors">
  3637. <div id="card-ha-sensors">
  3638. <div className="flex items-center justify-between mb-4">
  3639. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  3640. <Gauge className="w-5 h-5 text-bambu-green" />
  3641. {t('haSensors.sectionTitle')}
  3642. </h2>
  3643. <Button
  3644. className="whitespace-nowrap"
  3645. disabled={!printers?.length}
  3646. onClick={() => {
  3647. setEditingHASensor(null);
  3648. setShowHASensorModal(true);
  3649. }}
  3650. >
  3651. <Plus className="w-4 h-4" />
  3652. {t('haSensors.add')}
  3653. </Button>
  3654. </div>
  3655. {haSensors && haSensors.length > 0 ? (
  3656. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  3657. {haSensors.map((sensor) => {
  3658. const printer = printers?.find((p) => p.id === sensor.printer_id);
  3659. return (
  3660. <Card key={sensor.id}>
  3661. <CardContent className="py-4">
  3662. <div className="flex items-start justify-between gap-2">
  3663. <div className="min-w-0">
  3664. <div className="text-white font-medium truncate">{sensor.name}</div>
  3665. <div className="text-xs text-bambu-gray truncate">{sensor.entity_id}</div>
  3666. <div className="text-xs text-bambu-gray mt-1">
  3667. {printer?.name ?? t('haSensors.unknownPrinter')}
  3668. </div>
  3669. </div>
  3670. <Button
  3671. size="sm"
  3672. variant="secondary"
  3673. onClick={() => {
  3674. setEditingHASensor(sensor);
  3675. setShowHASensorModal(true);
  3676. }}
  3677. >
  3678. {t('common.edit')}
  3679. </Button>
  3680. </div>
  3681. <div className="flex flex-wrap gap-1 mt-3">
  3682. {sensor.block_print && (
  3683. <span className="px-2 py-0.5 text-xs rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
  3684. {t('haSensors.badgeBlocks')}
  3685. </span>
  3686. )}
  3687. {sensor.notify_on_alert && (
  3688. <span className="px-2 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
  3689. {t('haSensors.badgeNotifies')}
  3690. </span>
  3691. )}
  3692. {!sensor.show_on_printer_card && (
  3693. <span className="px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
  3694. {t('haSensors.badgeHidden')}
  3695. </span>
  3696. )}
  3697. </div>
  3698. </CardContent>
  3699. </Card>
  3700. );
  3701. })}
  3702. </div>
  3703. ) : (
  3704. <Card>
  3705. <CardContent className="py-8">
  3706. <div className="text-center text-bambu-gray">
  3707. <Gauge className="w-12 h-12 mx-auto mb-3 opacity-30" />
  3708. <p className="text-sm">{t('haSensors.empty')}</p>
  3709. </div>
  3710. </CardContent>
  3711. </Card>
  3712. )}
  3713. </div>
  3714. <div id="card-location-sensors" className="mt-8">
  3715. <div className="flex items-start justify-between gap-[20px] mb-4">
  3716. <div>
  3717. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  3718. <Gauge className="w-5 h-5 text-bambu-green" />
  3719. {t('locationHaSensors.sectionTitle')}
  3720. </h2>
  3721. <p className="text-sm text-bambu-gray mt-1">
  3722. {t('locationHaSensors.sectionDescription')}
  3723. </p>
  3724. </div>
  3725. <div className="flex items-center gap-2 pt-1 shrink-0">
  3726. <button
  3727. type="button"
  3728. onClick={() => setShowLocationSensorOptionsModal(true)}
  3729. className="p-2 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white transition-colors"
  3730. title={t('locationHaSensors.options.buttonLabel')}
  3731. aria-label={t('locationHaSensors.options.buttonLabel')}
  3732. >
  3733. <Cog className="w-4 h-4" />
  3734. </button>
  3735. <Button
  3736. className="whitespace-nowrap"
  3737. disabled={!haSensorLocations?.length}
  3738. onClick={() => {
  3739. setEditingLocationHASensor(null);
  3740. setShowLocationHASensorModal(true);
  3741. }}
  3742. >
  3743. <Plus className="w-4 h-4" />
  3744. {t('locationHaSensors.add')}
  3745. </Button>
  3746. </div>
  3747. </div>
  3748. {locationHaSensors && locationHaSensors.length > 0 ? (
  3749. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  3750. {(() => {
  3751. // Card order follows haSensorLocations (already sorted
  3752. // naturally by the backend — "Drybox 2" before "Drybox
  3753. // 10"), not the Map's insertion order, which is whatever
  3754. // order the sensors themselves happened to be created in.
  3755. const locationOrder = new Map((haSensorLocations ?? []).map((l, i) => [l.id, i]));
  3756. const grouped = locationHaSensors.reduce((map, sensor) => {
  3757. const list = map.get(sensor.location_id) ?? [];
  3758. list.push(sensor);
  3759. map.set(sensor.location_id, list);
  3760. return map;
  3761. }, new Map<number, LocationHASensor[]>());
  3762. return Array.from(grouped.entries()).sort(
  3763. ([a], [b]) =>
  3764. (locationOrder.get(a) ?? Number.MAX_SAFE_INTEGER) -
  3765. (locationOrder.get(b) ?? Number.MAX_SAFE_INTEGER)
  3766. );
  3767. })().map(([locationId, unsortedSensors]) => {
  3768. const location = haSensorLocations?.find((l) => l.id === locationId);
  3769. const iconForSensor = (sensor: LocationHASensor) =>
  3770. iconForHASensor({ device_class: sensor.device_class, state: sensor.last_state, kind: sensor.kind });
  3771. const sensors = [...unsortedSensors].sort(
  3772. (a, b) =>
  3773. (LOCATION_SENSOR_CATEGORY_ORDER[a.device_class ?? ''] ?? 99) -
  3774. (LOCATION_SENSOR_CATEGORY_ORDER[b.device_class ?? ''] ?? 99)
  3775. );
  3776. return (
  3777. <Card key={locationId}>
  3778. <CardContent className="py-4">
  3779. <div className="flex items-start justify-between gap-2">
  3780. <div className="text-white font-medium truncate">
  3781. {location?.name ?? t('locationHaSensors.unknownLocation')}
  3782. </div>
  3783. <button
  3784. type="button"
  3785. onClick={() =>
  3786. setDeleteLocationSensorsTarget({
  3787. locationName: location?.name ?? t('locationHaSensors.unknownLocation'),
  3788. sensorIds: sensors.map((sensor) => sensor.id),
  3789. })
  3790. }
  3791. className="p-1 text-bambu-gray hover:text-red-500 rounded transition-colors shrink-0"
  3792. title={t('common.delete')}
  3793. aria-label={t('common.delete')}
  3794. >
  3795. <Trash2 className="w-3.5 h-3.5" />
  3796. </button>
  3797. </div>
  3798. <div className="mt-2 space-y-1.5">
  3799. {sensors.map((sensor) => {
  3800. const Icon = iconForSensor(sensor);
  3801. const reading = locationSensorReadingsById.get(sensor.id);
  3802. const value = describeLocationSensorValue(reading, t);
  3803. const alertStatus =
  3804. colorizeLocationSensorValues && reading ? locationSensorReadingAlertStatus(reading) : null;
  3805. const valueColor =
  3806. locationSensorValueColorClass(
  3807. alertStatus,
  3808. locationSensorAboveColor,
  3809. locationSensorBelowColor,
  3810. locationSensorOptimalColor
  3811. ) || 'text-white';
  3812. return (
  3813. <div key={sensor.id} className="flex items-center min-w-0 text-xs text-bambu-gray">
  3814. <Icon className="w-3.5 h-3.5 shrink-0 mr-1.5" />
  3815. {value && <span className={`${valueColor} shrink-0 w-[52px] text-center`}>{value}</span>}
  3816. {value && <span className="shrink-0 mr-1.5">-</span>}
  3817. <span className="truncate mr-1.5" title={sensor.name}>
  3818. {sensor.entity_id}
  3819. </span>
  3820. <button
  3821. type="button"
  3822. onClick={() => {
  3823. setEditingLocationHASensor(sensor);
  3824. setShowLocationHASensorModal(true);
  3825. }}
  3826. className="p-1 text-bambu-gray hover:text-white rounded transition-colors shrink-0"
  3827. title={t('common.edit')}
  3828. aria-label={t('common.edit')}
  3829. >
  3830. <Pencil className="w-3.5 h-3.5" />
  3831. </button>
  3832. </div>
  3833. );
  3834. })}
  3835. </div>
  3836. <div className="flex flex-wrap gap-1 mt-3">
  3837. {(() => {
  3838. const notifying = sensors.filter((sensor) => sensor.notify_on_alert);
  3839. if (!notifying.length) return null;
  3840. return (
  3841. <span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
  3842. {t('haSensors.badgeNotifies')}
  3843. {notifying.map((sensor) => {
  3844. const Icon = iconForSensor(sensor);
  3845. return <Icon key={sensor.id} className="w-3 h-3" />;
  3846. })}
  3847. </span>
  3848. );
  3849. })()}
  3850. {(() => {
  3851. const hidden = sensors.filter((sensor) => !sensor.show_on_card);
  3852. if (!hidden.length) return null;
  3853. return (
  3854. <span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
  3855. {t('haSensors.badgeHidden')}
  3856. {hidden.map((sensor) => {
  3857. const Icon = iconForSensor(sensor);
  3858. return <Icon key={sensor.id} className="w-3 h-3" />;
  3859. })}
  3860. </span>
  3861. );
  3862. })()}
  3863. </div>
  3864. </CardContent>
  3865. </Card>
  3866. );
  3867. })}
  3868. </div>
  3869. ) : (
  3870. <Card>
  3871. <CardContent className="py-8">
  3872. <div className="text-center text-bambu-gray">
  3873. <Gauge className="w-12 h-12 mx-auto mb-3 opacity-30" />
  3874. <p className="text-sm">{t('locationHaSensors.empty')}</p>
  3875. </div>
  3876. </CardContent>
  3877. </Card>
  3878. )}
  3879. </div>
  3880. </div>
  3881. )}
  3882. {/* Notifications Tab */}
  3883. {activeTab === 'notifications' && (
  3884. <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
  3885. {/* Left Column: Providers */}
  3886. <div>
  3887. <div className="flex items-center justify-between mb-4">
  3888. <h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-providers">
  3889. <Bell className="w-5 h-5 text-bambu-green" />
  3890. {t('settings.providers')}
  3891. </h2>
  3892. <div className="flex items-center gap-2">
  3893. <Button
  3894. size="sm"
  3895. variant="secondary"
  3896. onClick={() => setShowLogViewer(true)}
  3897. >
  3898. <History className="w-4 h-4" />
  3899. {t('settings.log')}
  3900. </Button>
  3901. {notificationProviders && notificationProviders.length > 0 && (
  3902. <Button
  3903. size="sm"
  3904. variant="secondary"
  3905. onClick={() => {
  3906. setTestAllResult(null);
  3907. testAllMutation.mutate();
  3908. }}
  3909. disabled={testAllMutation.isPending}
  3910. >
  3911. {testAllMutation.isPending ? (
  3912. <Loader2 className="w-4 h-4 animate-spin" />
  3913. ) : (
  3914. <Send className="w-4 h-4" />
  3915. )}
  3916. {t('settings.testAll')}
  3917. </Button>
  3918. )}
  3919. <Button
  3920. size="sm"
  3921. onClick={() => {
  3922. setEditingProvider(null);
  3923. setShowNotificationModal(true);
  3924. }}
  3925. >
  3926. <Plus className="w-4 h-4" />
  3927. {t('settings.addNotificationProvider')}
  3928. </Button>
  3929. </div>
  3930. </div>
  3931. {/* Bed Cooled Threshold Setting */}
  3932. <Card className="mb-4">
  3933. <CardContent className="py-3">
  3934. <div className="flex items-center justify-between">
  3935. <div>
  3936. <p className="text-white text-sm font-medium">{t('settings.bedCooledThreshold')}</p>
  3937. <p className="text-xs text-bambu-gray">{t('settings.bedCooledThresholdDescription')}</p>
  3938. </div>
  3939. <div className="flex items-center gap-1">
  3940. <input
  3941. type="number"
  3942. min={20}
  3943. max={80}
  3944. step={1}
  3945. value={localSettings.bed_cooled_threshold ?? 35}
  3946. onChange={(e) => updateSetting('bed_cooled_threshold', Number(e.target.value))}
  3947. className="w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
  3948. />
  3949. <span className="text-sm text-bambu-gray">°C</span>
  3950. </div>
  3951. </div>
  3952. </CardContent>
  3953. </Card>
  3954. {/* Pause heads-up lead time (#2984) */}
  3955. <Card className="mb-4">
  3956. <CardContent className="py-3">
  3957. <div className="flex items-center justify-between">
  3958. <div>
  3959. <p className="text-white text-sm font-medium">{t('settings.pauseWarningMinutes')}</p>
  3960. <p className="text-xs text-bambu-gray">{t('settings.pauseWarningMinutesDescription')}</p>
  3961. </div>
  3962. <div className="flex items-center gap-1">
  3963. <input
  3964. type="number"
  3965. min={1}
  3966. max={120}
  3967. step={1}
  3968. value={localSettings.pause_warning_minutes ?? 5}
  3969. onChange={(e) => updateSetting('pause_warning_minutes', Number(e.target.value))}
  3970. onBlur={() => updateSetting('pause_warning_minutes', clampPauseWarningMinutes(localSettings.pause_warning_minutes))}
  3971. aria-label={t('settings.pauseWarningMinutes')}
  3972. className="w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
  3973. />
  3974. <span className="text-sm text-bambu-gray">{t('common.minutes')}</span>
  3975. </div>
  3976. </div>
  3977. </CardContent>
  3978. </Card>
  3979. {/* User Notifications Toggle */}
  3980. <Card className="mb-4">
  3981. <CardContent className="py-3">
  3982. <div className={`flex items-center justify-between ${!advancedAuthStatus?.advanced_auth_enabled ? 'opacity-50' : ''}`}>
  3983. <div>
  3984. <p className="text-white text-sm font-medium">{t('settings.userNotificationsEnabled')}</p>
  3985. <p className="text-xs text-bambu-gray">
  3986. {!advancedAuthStatus?.advanced_auth_enabled
  3987. ? t('settings.userNotificationsDisabledHint')
  3988. : t('settings.userNotificationsEnabledDescription')}
  3989. </p>
  3990. </div>
  3991. <label className="relative inline-flex items-center cursor-pointer">
  3992. <input
  3993. type="checkbox"
  3994. className="sr-only peer"
  3995. checked={localSettings.user_notifications_enabled ?? true}
  3996. disabled={!advancedAuthStatus?.advanced_auth_enabled}
  3997. onChange={(e) => updateSetting('user_notifications_enabled', e.target.checked)}
  3998. />
  3999. <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 peer-disabled:cursor-not-allowed"></div>
  4000. </label>
  4001. </div>
  4002. </CardContent>
  4003. </Card>
  4004. {/* Test All Results */}
  4005. {testAllResult && (
  4006. <Card className="mb-4">
  4007. <CardContent className="py-3">
  4008. <div className="flex items-center justify-between mb-2">
  4009. <span className="text-sm font-medium text-white">{t('settings.testResults')}</span>
  4010. <button
  4011. onClick={() => setTestAllResult(null)}
  4012. className="text-bambu-gray hover:text-white text-xs"
  4013. >
  4014. {t('common.dismiss')}
  4015. </button>
  4016. </div>
  4017. <div className="flex items-center gap-4 text-sm mb-2">
  4018. <span className="flex items-center gap-1 text-bambu-green">
  4019. <CheckCircle className="w-4 h-4" />
  4020. {t('settings.testPassedCount', { count: testAllResult.success })}
  4021. </span>
  4022. {testAllResult.failed > 0 && (
  4023. <span className="flex items-center gap-1 text-red-700 dark:text-red-400">
  4024. <XCircle className="w-4 h-4" />
  4025. {t('settings.testFailedCount', { count: testAllResult.failed })}
  4026. </span>
  4027. )}
  4028. </div>
  4029. {testAllResult.results.filter(r => !r.success).length > 0 && (
  4030. <div className="space-y-1 mt-2 pt-2 border-t border-bambu-dark-tertiary">
  4031. {testAllResult.results.filter(r => !r.success).map((result) => (
  4032. <div key={result.provider_id} className="text-xs text-red-700 dark:text-red-400">
  4033. <span className="font-medium">{result.provider_name}:</span> {result.message}
  4034. </div>
  4035. ))}
  4036. </div>
  4037. )}
  4038. </CardContent>
  4039. </Card>
  4040. )}
  4041. {providersLoading ? (
  4042. <div className="flex justify-center py-12">
  4043. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  4044. </div>
  4045. ) : notificationProviders && notificationProviders.length > 0 ? (
  4046. <div className="space-y-3">
  4047. {notificationProviders.map((provider) => (
  4048. <NotificationProviderCard
  4049. key={provider.id}
  4050. provider={provider}
  4051. onEdit={(p) => {
  4052. setEditingProvider(p);
  4053. setShowNotificationModal(true);
  4054. }}
  4055. />
  4056. ))}
  4057. </div>
  4058. ) : (
  4059. <Card>
  4060. <CardContent className="py-8">
  4061. <div className="text-center text-bambu-gray">
  4062. <Bell className="w-12 h-12 mx-auto mb-3 opacity-30" />
  4063. <p className="text-sm font-medium text-white mb-2">{t('settings.noProvidersTitle')}</p>
  4064. <p className="text-xs mb-3">{t('settings.noProvidersDescription')}</p>
  4065. <Button
  4066. size="sm"
  4067. onClick={() => {
  4068. setEditingProvider(null);
  4069. setShowNotificationModal(true);
  4070. }}
  4071. >
  4072. <Plus className="w-4 h-4" />
  4073. {t('settings.addProvider')}
  4074. </Button>
  4075. </div>
  4076. </CardContent>
  4077. </Card>
  4078. )}
  4079. </div>
  4080. {/* Right Column: Templates */}
  4081. <div>
  4082. <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-2" id="card-templates">
  4083. <FileText className="w-5 h-5 text-bambu-green" />
  4084. {t('settings.messageTemplates')}
  4085. </h2>
  4086. <p className="text-sm text-bambu-gray mb-3">
  4087. {t('settings.messageTemplatesDescription')}
  4088. </p>
  4089. {/* Filter input */}
  4090. <div className="relative mb-3">
  4091. <Search className="w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
  4092. <input
  4093. type="text"
  4094. value={templateFilter}
  4095. onChange={(e) => setTemplateFilter(e.target.value)}
  4096. placeholder={t('settings.filterTemplates', 'Filter templates…')}
  4097. className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  4098. />
  4099. {templateFilter && (
  4100. <button
  4101. onClick={() => setTemplateFilter('')}
  4102. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-bambu-gray hover:text-white"
  4103. aria-label="Clear filter"
  4104. >
  4105. <X className="w-3.5 h-3.5" />
  4106. </button>
  4107. )}
  4108. </div>
  4109. {templatesLoading ? (
  4110. <div className="flex justify-center py-8">
  4111. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  4112. </div>
  4113. ) : notificationTemplates && notificationTemplates.length > 0 ? (
  4114. (() => {
  4115. const filter = templateFilter.trim().toLowerCase();
  4116. const filtered = [...notificationTemplates]
  4117. .sort((a, b) => a.name.localeCompare(b.name))
  4118. .filter(tpl =>
  4119. !filter ||
  4120. tpl.name.toLowerCase().includes(filter) ||
  4121. (tpl.title_template || '').toLowerCase().includes(filter)
  4122. );
  4123. if (filtered.length === 0) {
  4124. return (
  4125. <p className="text-sm text-bambu-gray italic text-center py-6">
  4126. {t('settings.noTemplatesMatch', 'No templates match your filter.')}
  4127. </p>
  4128. );
  4129. }
  4130. return (
  4131. <div className="space-y-2">
  4132. {filtered.map((template) => (
  4133. <Card
  4134. key={template.id}
  4135. className="cursor-pointer hover:border-bambu-green/50 transition-colors"
  4136. onClick={() => setEditingTemplate(template)}
  4137. >
  4138. <CardContent className="py-2.5 px-3">
  4139. <div className="flex items-center justify-between">
  4140. <div className="min-w-0 flex-1">
  4141. <p className="text-white font-medium text-sm truncate">{template.name}</p>
  4142. <p className="text-bambu-gray text-xs truncate mt-0.5">
  4143. {template.title_template}
  4144. </p>
  4145. </div>
  4146. <button
  4147. className="p-1.5 hover:bg-bambu-dark-tertiary rounded transition-colors shrink-0 ml-2"
  4148. onClick={(e) => {
  4149. e.stopPropagation();
  4150. setEditingTemplate(template);
  4151. }}
  4152. >
  4153. <Edit2 className="w-4 h-4 text-bambu-gray" />
  4154. </button>
  4155. </div>
  4156. </CardContent>
  4157. </Card>
  4158. ))}
  4159. </div>
  4160. );
  4161. })()
  4162. ) : (
  4163. <Card>
  4164. <CardContent className="py-8">
  4165. <div className="text-center text-bambu-gray">
  4166. <FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
  4167. <p className="text-sm">{t('settings.noTemplatesAvailable')}</p>
  4168. </div>
  4169. </CardContent>
  4170. </Card>
  4171. )}
  4172. </div>
  4173. </div>
  4174. )}
  4175. {/* API Keys Tab */}
  4176. {activeTab === 'camera' && (
  4177. <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
  4178. {/* Everything about cameras in one place: external cameras, the
  4179. long-lived tokens other tools use to reach a stream, and the
  4180. streaming overlay built from those tokens. */}
  4181. <div className="min-w-0 space-y-6">
  4182. <Card id="card-camera">
  4183. <CardHeader>
  4184. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  4185. <Video className="w-5 h-5 text-bambu-green" />
  4186. {t('settings.camera')}
  4187. </h2>
  4188. </CardHeader>
  4189. <CardContent className="space-y-3">
  4190. {/* Camera View Mode used to live here. It is now the split
  4191. camera button on each printer card, where the choice is made
  4192. per stream instead of once for the whole install. The stored
  4193. setting stays as the default a fresh browser starts from. */}
  4194. {/* Chamber light for the camera (#1655): one setting for the whole
  4195. install, with a printer list only for "Selected printers".
  4196. Nothing here grows with the number of printers. */}
  4197. <section className="space-y-2">
  4198. <div>
  4199. <h3 className="text-sm font-medium text-white">{t('settings.cameraLight')}</h3>
  4200. <p className="text-xs text-bambu-gray">{t('settings.cameraLightHint')}</p>
  4201. </div>
  4202. <div role="radiogroup" aria-label={t('settings.cameraLight')} className="inline-flex rounded-lg border border-bambu-dark-tertiary overflow-hidden">
  4203. {CAMERA_LIGHT_MODES.map(mode => (
  4204. <label key={mode} className="cursor-pointer">
  4205. <input
  4206. type="radio"
  4207. name="camera-light-mode"
  4208. value={mode}
  4209. checked={cameraLightMode === mode}
  4210. onChange={() => updateSetting('camera_light_mode', mode)}
  4211. className="sr-only peer"
  4212. />
  4213. <span className="block px-3 py-1.5 text-xs text-bambu-gray peer-checked:bg-bambu-green peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-inset peer-focus-visible:ring-white hover:text-white">
  4214. {t(`settings.cameraLightMode_${mode}`)}
  4215. </span>
  4216. </label>
  4217. ))}
  4218. </div>
  4219. {cameraLightMode !== 'off' && (
  4220. <div>
  4221. <div className="flex items-center gap-2">
  4222. <label htmlFor="camera-light-delay" className="text-xs text-bambu-gray">{t('settings.cameraLightDelay')}</label>
  4223. <NumberInput
  4224. id="camera-light-delay"
  4225. min={0}
  4226. max={5}
  4227. step="0.5"
  4228. integer={false}
  4229. fallback={2}
  4230. value={localSettings.camera_light_delay ?? 2}
  4231. onChange={(v) => updateSetting('camera_light_delay', v)}
  4232. className="w-16 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs text-center focus:border-bambu-green focus:outline-none"
  4233. />
  4234. <span className="text-xs text-bambu-gray">{t('settings.cameraLightDelayUnit')}</span>
  4235. </div>
  4236. <p className="text-xs text-bambu-gray opacity-75 mt-1">{t('settings.cameraLightDelayHint')}</p>
  4237. </div>
  4238. )}
  4239. {cameraLightMode === 'selected' && (
  4240. <div className="flex flex-wrap items-center gap-1.5" data-testid="camera-light-printers">
  4241. {lightPrinters.length === 0 && (
  4242. <span className="text-xs text-bambu-gray italic">{t('settings.cameraLightNoneSelected')}</span>
  4243. )}
  4244. {lightPrinters.map(printer => (
  4245. <span key={printer.id} className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full bg-bambu-dark-tertiary text-xs text-white">
  4246. {printer.name}
  4247. <button
  4248. type="button"
  4249. onClick={() => handleUpdatePrinterCamera(printer.id, { lightAuto: false })}
  4250. aria-label={t('settings.cameraLightRemovePrinter', { name: printer.name })}
  4251. className="p-0.5 rounded-full text-bambu-gray hover:text-white"
  4252. >
  4253. <X className="w-3 h-3" />
  4254. </button>
  4255. </span>
  4256. ))}
  4257. <PrinterSearchPicker
  4258. printers={(printers ?? []).filter(p => !p.camera_light_auto)}
  4259. onPick={(p) => handleUpdatePrinterCamera(p.id, { lightAuto: true })}
  4260. label={t('settings.cameraLightAddPrinter')}
  4261. />
  4262. </div>
  4263. )}
  4264. </section>
  4265. {/* External cameras: only the printers that have one, so a farm
  4266. sees a handful of rows, not every printer it owns. */}
  4267. <section className="space-y-2 pt-4 border-t border-bambu-dark-tertiary">
  4268. <div>
  4269. <h3 className="text-sm font-medium text-white">{t('settings.externalCameras')}</h3>
  4270. <p className="text-xs text-bambu-gray">{t('settings.externalCamerasDescription')}</p>
  4271. </div>
  4272. {externalCameraPrinters.length > 0 && (
  4273. <div className="rounded-lg border border-bambu-dark-tertiary" data-testid="external-camera-list">
  4274. {externalCameraPrinters.map(printer => {
  4275. const isOpen = openCameraPrinterId === printer.id;
  4276. const summary = externalCameraSummary(printer);
  4277. return (
  4278. <div key={printer.id} className="border-b border-bambu-dark-tertiary last:border-b-0">
  4279. <div className="flex items-center gap-2 px-2 py-1.5">
  4280. <button
  4281. type="button"
  4282. onClick={() => setOpenCameraPrinterId(isOpen ? null : printer.id)}
  4283. aria-expanded={isOpen}
  4284. aria-label={`${t('settings.cameraShowSettings')}: ${printer.name}`}
  4285. className="flex flex-1 min-w-0 items-center gap-2 text-left"
  4286. >
  4287. <span className="min-w-0 flex-1">
  4288. <span className="block text-sm text-white truncate">{printer.name}</span>
  4289. <span className="block text-[11px] text-bambu-gray truncate" title={summary}>{summary}</span>
  4290. </span>
  4291. <ChevronDown className={`w-4 h-4 shrink-0 text-bambu-gray transition-transform ${isOpen ? 'rotate-180' : ''}`} />
  4292. </button>
  4293. <button
  4294. type="button"
  4295. onClick={() => {
  4296. handleUpdatePrinterCamera(printer.id, { enabled: false });
  4297. if (isOpen) setOpenCameraPrinterId(null);
  4298. }}
  4299. aria-label={t('settings.externalCameraRemove', { name: printer.name })}
  4300. title={t('settings.externalCameraRemove', { name: printer.name })}
  4301. className="p-1 rounded text-bambu-gray hover:text-red-400"
  4302. >
  4303. <Trash2 className="w-3.5 h-3.5" />
  4304. </button>
  4305. </div>
  4306. {isOpen && (
  4307. <div className="space-y-2 px-2 pb-3 pt-1 bg-bambu-dark/40">
  4308. {printer.external_camera_type === 'usb' && !usbManualEntry[printer.id] ? (
  4309. <UsbCameraDevicePicker
  4310. printerName={printer.name}
  4311. value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
  4312. onSelect={(device) => handleCameraUrlChange(printer.id, device)}
  4313. onEnterManually={() => setUsbManualEntry(prev => ({ ...prev, [printer.id]: true }))}
  4314. />
  4315. ) : (
  4316. <>
  4317. <input
  4318. type="text"
  4319. aria-label={`${t('settings.cameraPlaceholderUrl')}: ${printer.name}`}
  4320. placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
  4321. value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
  4322. onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
  4323. className="w-full px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  4324. />
  4325. {printer.external_camera_type === 'usb' && (
  4326. <button
  4327. type="button"
  4328. onClick={() => setUsbManualEntry(prev => ({ ...prev, [printer.id]: false }))}
  4329. className="text-xs text-bambu-green hover:underline"
  4330. >
  4331. {t('settings.usbCameraPickFromList')}
  4332. </button>
  4333. )}
  4334. </>
  4335. )}
  4336. <div className="flex flex-wrap items-center gap-2">
  4337. <select
  4338. value={printer.external_camera_type || 'mjpeg'}
  4339. onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
  4340. className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  4341. >
  4342. <option value="mjpeg">{t('settings.cameraTypeMjpeg')}</option>
  4343. <option value="rtsp">{t('settings.cameraTypeRtsp')}</option>
  4344. <option value="snapshot">{t('settings.cameraTypeSnapshot')}</option>
  4345. <option value="usb">{t('settings.cameraTypeUsb')}</option>
  4346. </select>
  4347. <label className="flex items-center gap-1 text-xs text-bambu-gray">
  4348. {t('settings.cameraRotation')}
  4349. <select
  4350. value={printer.camera_rotation || 0}
  4351. onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
  4352. className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
  4353. >
  4354. <option value={0}>0°</option>
  4355. <option value={90}>90°</option>
  4356. <option value={180}>180°</option>
  4357. <option value={270}>270°</option>
  4358. </select>
  4359. </label>
  4360. <Button
  4361. size="sm"
  4362. variant="secondary"
  4363. onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}
  4364. disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}
  4365. >
  4366. {extCameraTestLoading[printer.id] ? (
  4367. <Loader2 className="w-4 h-4 animate-spin" />
  4368. ) : (
  4369. t('settings.test')
  4370. )}
  4371. </Button>
  4372. </div>
  4373. {extCameraTestResults[printer.id] && (
  4374. <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
  4375. {extCameraTestResults[printer.id]?.success ? (
  4376. <>
  4377. <CheckCircle className="w-3 h-3" />
  4378. {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
  4379. </>
  4380. ) : (
  4381. <>
  4382. <XCircle className="w-3 h-3" />
  4383. {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')}
  4384. </>
  4385. )}
  4386. </div>
  4387. )}
  4388. {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
  4389. <div className="space-y-1">
  4390. <label className="text-xs text-bambu-gray flex items-center gap-1">
  4391. {t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}
  4392. <span title={t('settings.cameraSnapshotUrlHelp')} className="cursor-help" aria-label={t('settings.cameraSnapshotUrlHelp')}>
  4393. <Info className="w-3 h-3" />
  4394. </span>
  4395. </label>
  4396. <div className="flex gap-2">
  4397. <input
  4398. type="text"
  4399. placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
  4400. value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
  4401. onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
  4402. className="flex-1 min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  4403. />
  4404. <Button
  4405. size="sm"
  4406. variant="secondary"
  4407. onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
  4408. disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
  4409. >
  4410. {extCameraTestLoading[printer.id] ? (
  4411. <Loader2 className="w-4 h-4 animate-spin" />
  4412. ) : (
  4413. t('settings.test')
  4414. )}
  4415. </Button>
  4416. </div>
  4417. </div>
  4418. )}
  4419. </div>
  4420. )}
  4421. </div>
  4422. );
  4423. })}
  4424. </div>
  4425. )}
  4426. {printers && printers.length > 0 ? (
  4427. <PrinterSearchPicker
  4428. printers={printers.filter(p => !p.external_camera_enabled)}
  4429. onPick={(p) => {
  4430. handleUpdatePrinterCamera(p.id, { enabled: true });
  4431. // A camera just added needs its URL next.
  4432. setOpenCameraPrinterId(p.id);
  4433. }}
  4434. label={t('settings.externalCameraAdd')}
  4435. />
  4436. ) : (
  4437. <p className="text-xs text-bambu-gray italic">{t('settings.noPrintersConfigured')}</p>
  4438. )}
  4439. </section>
  4440. </CardContent>
  4441. </Card>
  4442. {/* Long-lived camera-stream tokens (#1108) */}
  4443. <Card>
  4444. <CardHeader>
  4445. <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-camera-tokens">
  4446. <Video className="w-4 h-4 text-bambu-green" />
  4447. {t('cameraTokens.title', 'Camera API Tokens')}
  4448. </h3>
  4449. </CardHeader>
  4450. <CardContent>
  4451. <CameraTokensSection key={cameraTokenRevision} />
  4452. </CardContent>
  4453. </Card>
  4454. </div>
  4455. <div className="min-w-0">
  4456. {/* Streaming-overlay URL builder (#1422). Sits beside the camera
  4457. tokens it usually needs — an overlay for a login-enabled
  4458. deployment is a token plus a URL, and both are made here. */}
  4459. <Card>
  4460. <CardHeader>
  4461. <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-stream-overlay">
  4462. <MonitorPlay className="w-4 h-4 text-bambu-green" />
  4463. {t('streamOverlay.builder.title', 'Streaming Overlay')}
  4464. </h3>
  4465. </CardHeader>
  4466. <CardContent>
  4467. <StreamOverlayBuilder onTokenCreated={() => setCameraTokenRevision((revision) => revision + 1)} />
  4468. </CardContent>
  4469. </Card>
  4470. </div>
  4471. </div>
  4472. )}
  4473. {activeTab === 'apikeys' && (
  4474. <div className={hasPermission('api_keys:read')
  4475. ? 'grid grid-cols-1 lg:grid-cols-2 gap-4'
  4476. : 'grid grid-cols-1 gap-4'}>
  4477. {/* Left Column - API Keys Management. Admin-gated content
  4478. (webhook keys, webhook docs) is hidden from users without
  4479. api_keys:read. Camera tokens moved to the Camera tab. */}
  4480. <div className="min-w-0">
  4481. {hasPermission('api_keys:read') && <>
  4482. <div className="flex items-start justify-between gap-4 mb-6">
  4483. <div className="flex-1">
  4484. <h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-createapi">
  4485. <Key className="w-5 h-5 text-bambu-green" />
  4486. {t('settings.apiKeys')}
  4487. </h2>
  4488. <p className="text-sm text-bambu-gray mt-1">
  4489. {t('settings.apiKeysDescription')}
  4490. </p>
  4491. </div>
  4492. <Button size="sm" onClick={() => setShowCreateAPIKey(true)} className="flex-shrink-0">
  4493. <Plus className="w-4 h-4" />
  4494. {t('settings.createKey')}
  4495. </Button>
  4496. </div>
  4497. {/* Created Key Display */}
  4498. {createdAPIKey && (
  4499. <Card className="mb-6 border-bambu-green">
  4500. <CardContent className="py-4">
  4501. <div className="flex items-start gap-3">
  4502. <CheckCircle className="w-5 h-5 text-bambu-green flex-shrink-0 mt-0.5" />
  4503. <div className="flex-1">
  4504. <p className="text-white font-medium mb-1">{t('settings.apiKeyCreated')}</p>
  4505. <p className="text-sm text-bambu-gray mb-2">
  4506. {t('settings.apiKeyCopyWarning')}
  4507. </p>
  4508. <div className="flex items-center gap-2 bg-bambu-dark rounded-lg p-2">
  4509. <code className="flex-1 text-sm text-bambu-green font-mono break-all">
  4510. {createdAPIKey}
  4511. </code>
  4512. <Button
  4513. variant="secondary"
  4514. size="sm"
  4515. onClick={async () => {
  4516. try {
  4517. if (navigator.clipboard && navigator.clipboard.writeText) {
  4518. await navigator.clipboard.writeText(createdAPIKey);
  4519. } else {
  4520. const textArea = document.createElement('textarea');
  4521. textArea.value = createdAPIKey;
  4522. textArea.style.position = 'fixed';
  4523. textArea.style.left = '-999999px';
  4524. document.body.appendChild(textArea);
  4525. textArea.select();
  4526. document.execCommand('copy');
  4527. document.body.removeChild(textArea);
  4528. }
  4529. showToast(t('settings.toast.keyCopied'));
  4530. } catch {
  4531. showToast(t('settings.toast.copyFailed'), 'error');
  4532. }
  4533. }}
  4534. >
  4535. <Copy className="w-4 h-4" />
  4536. </Button>
  4537. </div>
  4538. <div className="flex gap-2 mt-3">
  4539. <Button
  4540. variant="secondary"
  4541. size="sm"
  4542. onClick={() => {
  4543. setTestApiKey(createdAPIKey);
  4544. showToast(t('settings.toast.keyAddedToBrowser'));
  4545. }}
  4546. >
  4547. {t('settings.useInApiBrowser')}
  4548. </Button>
  4549. <Button
  4550. variant="secondary"
  4551. size="sm"
  4552. onClick={() => setShowApiKeyQR(true)}
  4553. >
  4554. <QrCode className="w-4 h-4" />
  4555. {t('settings.apiKeyQrButton')}
  4556. </Button>
  4557. <Button
  4558. variant="secondary"
  4559. size="sm"
  4560. onClick={() => {
  4561. setShowApiKeyQR(false);
  4562. setCreatedAPIKey(null);
  4563. }}
  4564. >
  4565. {t('common.dismiss')}
  4566. </Button>
  4567. </div>
  4568. </div>
  4569. </div>
  4570. </CardContent>
  4571. </Card>
  4572. )}
  4573. {/* QR code with base URL + key for mobile clients. Prefer the
  4574. configured External URL; fall back to the current origin. */}
  4575. {showApiKeyQR && createdAPIKey && (
  4576. <ApiKeyQRCodeModal
  4577. apiKey={createdAPIKey}
  4578. baseUrl={localSettings?.external_url || undefined}
  4579. onClose={() => setShowApiKeyQR(false)}
  4580. />
  4581. )}
  4582. {/* Create Key Form */}
  4583. {showCreateAPIKey && (
  4584. <Card className="mb-6">
  4585. <CardHeader>
  4586. <h3 className="text-base font-semibold text-white">{t('settings.createNewApiKey')}</h3>
  4587. </CardHeader>
  4588. <CardContent className="space-y-3">
  4589. <div>
  4590. <label className="block text-sm text-bambu-gray mb-1">{t('settings.keyName')}</label>
  4591. <input
  4592. type="text"
  4593. value={newAPIKeyName}
  4594. onChange={(e) => setNewAPIKeyName(e.target.value)}
  4595. placeholder={t('settings.keyNamePlaceholder')}
  4596. 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"
  4597. />
  4598. </div>
  4599. <div>
  4600. <label className="block text-sm text-bambu-gray mb-2">{t('common.permissions')}</label>
  4601. <div className="space-y-2">
  4602. <label className="flex items-center gap-3 cursor-pointer">
  4603. <input
  4604. type="checkbox"
  4605. checked={newAPIKeyPermissions.can_read_status}
  4606. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_read_status: e.target.checked }))}
  4607. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4608. />
  4609. <div>
  4610. <span className="text-white">{t('settings.readStatus')}</span>
  4611. <p className="text-xs text-bambu-gray">{t('settings.readStatusDescription')}</p>
  4612. </div>
  4613. </label>
  4614. <label className="flex items-center gap-3 cursor-pointer">
  4615. <input
  4616. type="checkbox"
  4617. checked={newAPIKeyPermissions.can_queue}
  4618. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_queue: e.target.checked }))}
  4619. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4620. />
  4621. <div>
  4622. <span className="text-white">{t('settings.manageQueue')}</span>
  4623. <p className="text-xs text-bambu-gray">{t('settings.manageQueueDescription')}</p>
  4624. </div>
  4625. </label>
  4626. <label className="flex items-center gap-3 cursor-pointer">
  4627. <input
  4628. type="checkbox"
  4629. checked={newAPIKeyPermissions.can_control_printer}
  4630. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_control_printer: e.target.checked }))}
  4631. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4632. />
  4633. <div>
  4634. <span className="text-white">{t('settings.controlPrinter')}</span>
  4635. <p className="text-xs text-bambu-gray">{t('settings.controlPrinterDescription')}</p>
  4636. </div>
  4637. </label>
  4638. <label className="flex items-center gap-3 cursor-pointer">
  4639. <input
  4640. type="checkbox"
  4641. checked={newAPIKeyPermissions.can_manage_library}
  4642. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_library: e.target.checked }))}
  4643. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4644. />
  4645. <div>
  4646. <span className="text-white">{t('settings.manageLibrary')}</span>
  4647. <p className="text-xs text-bambu-gray">{t('settings.manageLibraryDescription')}</p>
  4648. </div>
  4649. </label>
  4650. <label className="flex items-center gap-3 cursor-pointer">
  4651. <input
  4652. type="checkbox"
  4653. checked={newAPIKeyPermissions.can_manage_inventory}
  4654. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_inventory: e.target.checked }))}
  4655. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4656. />
  4657. <div>
  4658. <span className="text-white">{t('settings.manageInventory')}</span>
  4659. <p className="text-xs text-bambu-gray">{t('settings.manageInventoryDescription')}</p>
  4660. </div>
  4661. </label>
  4662. <label className="flex items-center gap-3 cursor-pointer">
  4663. <input
  4664. type="checkbox"
  4665. checked={newAPIKeyPermissions.can_manage_maintenance}
  4666. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_maintenance: e.target.checked }))}
  4667. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4668. />
  4669. <div>
  4670. <span className="text-white">{t('settings.manageMaintenance')}</span>
  4671. <p className="text-xs text-bambu-gray">{t('settings.manageMaintenanceDescription')}</p>
  4672. </div>
  4673. </label>
  4674. <label className="flex items-center gap-3 cursor-pointer">
  4675. <input
  4676. type="checkbox"
  4677. checked={newAPIKeyPermissions.can_manage_archives}
  4678. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_archives: e.target.checked }))}
  4679. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4680. />
  4681. <div>
  4682. <span className="text-white">{t('settings.manageArchives')}</span>
  4683. <p className="text-xs text-bambu-gray">{t('settings.manageArchivesDescription')}</p>
  4684. </div>
  4685. </label>
  4686. <label className="flex items-center gap-3 cursor-pointer">
  4687. <input
  4688. type="checkbox"
  4689. checked={newAPIKeyPermissions.can_manage_projects}
  4690. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_projects: e.target.checked }))}
  4691. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4692. />
  4693. <div>
  4694. <span className="text-white">{t('settings.manageProjects')}</span>
  4695. <p className="text-xs text-bambu-gray">{t('settings.manageProjectsDescription')}</p>
  4696. </div>
  4697. </label>
  4698. <label className="flex items-center gap-3 cursor-pointer">
  4699. <input
  4700. type="checkbox"
  4701. checked={newAPIKeyPermissions.can_access_cloud}
  4702. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_access_cloud: e.target.checked }))}
  4703. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4704. />
  4705. <div>
  4706. <span className="text-white">{t('settings.cloudAccess', 'Allow cloud access')}</span>
  4707. <p className="text-xs text-bambu-gray">{t('settings.cloudAccessDescription', 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.')}</p>
  4708. </div>
  4709. </label>
  4710. <label className="flex items-center gap-3 cursor-pointer">
  4711. <input
  4712. type="checkbox"
  4713. checked={newAPIKeyPermissions.can_update_energy_cost}
  4714. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_update_energy_cost: e.target.checked }))}
  4715. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4716. />
  4717. <div>
  4718. <span className="text-white">{t('settings.updateEnergyCost')}</span>
  4719. <p className="text-xs text-bambu-gray">{t('settings.updateEnergyCostDescription')}</p>
  4720. </div>
  4721. </label>
  4722. <label className="flex items-center gap-3 cursor-pointer">
  4723. <input
  4724. type="checkbox"
  4725. checked={newAPIKeyPermissions.can_send_notifications}
  4726. onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_send_notifications: e.target.checked }))}
  4727. className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
  4728. />
  4729. <div>
  4730. <span className="text-white">{t('settings.sendNotifications')}</span>
  4731. <p className="text-xs text-bambu-gray">{t('settings.sendNotificationsDescription')}</p>
  4732. </div>
  4733. </label>
  4734. </div>
  4735. </div>
  4736. <div className="flex items-center gap-2 pt-2">
  4737. <Button
  4738. onClick={() => createAPIKeyMutation.mutate({
  4739. name: newAPIKeyName || t('settings.unnamedKey'),
  4740. ...newAPIKeyPermissions,
  4741. })}
  4742. disabled={createAPIKeyMutation.isPending}
  4743. >
  4744. {createAPIKeyMutation.isPending ? (
  4745. <Loader2 className="w-4 h-4 animate-spin" />
  4746. ) : (
  4747. <Plus className="w-4 h-4" />
  4748. )}
  4749. {t('settings.createKey')}
  4750. </Button>
  4751. <Button variant="secondary" onClick={() => setShowCreateAPIKey(false)}>
  4752. {t('common.cancel')}
  4753. </Button>
  4754. </div>
  4755. </CardContent>
  4756. </Card>
  4757. )}
  4758. {/* Existing Keys List */}
  4759. {apiKeysLoading ? (
  4760. <div className="flex justify-center py-12">
  4761. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  4762. </div>
  4763. ) : apiKeys && apiKeys.length > 0 ? (
  4764. <div className="space-y-3">
  4765. {apiKeys.map((key) => (
  4766. <Card key={key.id}>
  4767. <CardContent className="py-3">
  4768. <div className="flex items-center justify-between">
  4769. <div className="flex items-center gap-3">
  4770. <Key className={`w-5 h-5 ${key.enabled ? 'text-bambu-green' : 'text-bambu-gray'}`} />
  4771. <div>
  4772. <p className="text-white font-medium">{key.name}</p>
  4773. <p className="text-xs text-bambu-gray">
  4774. {key.key_prefix}••••••••
  4775. {key.last_used && ` · ${t('settings.lastUsed')}: ${formatDateOnly(key.last_used)}`}
  4776. </p>
  4777. </div>
  4778. </div>
  4779. <div className="flex items-center gap-2">
  4780. <div className="flex gap-1 text-xs flex-wrap justify-end">
  4781. {key.can_read_status && (
  4782. <span className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 rounded">{t('settings.read')}</span>
  4783. )}
  4784. {key.can_queue && (
  4785. <span className="px-1.5 py-0.5 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 rounded">{t('queue.title')}</span>
  4786. )}
  4787. {key.can_control_printer && (
  4788. <span className="px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400 rounded">{t('settings.control')}</span>
  4789. )}
  4790. {key.can_manage_library && (
  4791. <span className="px-1.5 py-0.5 bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 rounded">{t('settings.libraryBadge')}</span>
  4792. )}
  4793. {key.can_manage_inventory && (
  4794. <span className="px-1.5 py-0.5 bg-pink-100 dark:bg-pink-500/20 text-pink-700 dark:text-pink-400 rounded">{t('settings.inventoryBadge')}</span>
  4795. )}
  4796. {key.can_manage_maintenance && (
  4797. <span className="px-1.5 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 rounded">{t('settings.maintenanceBadge')}</span>
  4798. )}
  4799. {key.can_manage_archives && (
  4800. <span className="px-1.5 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-400 rounded">{t('settings.archivesBadge')}</span>
  4801. )}
  4802. {key.can_manage_projects && (
  4803. <span className="px-1.5 py-0.5 bg-lime-100 dark:bg-lime-500/20 text-lime-700 dark:text-lime-400 rounded">{t('settings.projectsBadge')}</span>
  4804. )}
  4805. {key.can_access_cloud && (
  4806. <span className="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400 rounded">{t('settings.cloudBadge', 'Cloud')}</span>
  4807. )}
  4808. {key.can_update_energy_cost && (
  4809. <span className="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 rounded">{t('settings.energyCostBadge')}</span>
  4810. )}
  4811. {key.can_send_notifications && (
  4812. <span className="px-1.5 py-0.5 bg-sky-100 dark:bg-sky-500/20 text-sky-700 dark:text-sky-400 rounded">{t('settings.sendNotificationsBadge')}</span>
  4813. )}
  4814. {key.user_id === null && (
  4815. <span
  4816. className="px-1.5 py-0.5 bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 rounded"
  4817. title={t('settings.legacyKeyTooltip', 'Created before per-user ownership; recreate to use cloud access')}
  4818. >
  4819. {t('settings.legacyKey', 'Legacy')}
  4820. </span>
  4821. )}
  4822. </div>
  4823. <Button
  4824. variant="secondary"
  4825. size="sm"
  4826. onClick={() => setShowDeleteAPIKeyConfirm(key.id)}
  4827. >
  4828. <Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
  4829. </Button>
  4830. </div>
  4831. </div>
  4832. </CardContent>
  4833. </Card>
  4834. ))}
  4835. </div>
  4836. ) : (
  4837. <Card>
  4838. <CardContent className="py-12">
  4839. <div className="text-center text-bambu-gray">
  4840. <Key className="w-16 h-16 mx-auto mb-4 opacity-30" />
  4841. <p className="text-lg font-medium text-white mb-2">{t('settings.apiKeysEmptyTitle')}</p>
  4842. <p className="text-sm mb-4">{t('settings.apiKeysEmptyDescription')}</p>
  4843. <Button onClick={() => setShowCreateAPIKey(true)}>
  4844. <Plus className="w-4 h-4" />
  4845. {t('settings.createFirstKey')}
  4846. </Button>
  4847. </div>
  4848. </CardContent>
  4849. </Card>
  4850. )}
  4851. {/* Webhook Documentation */}
  4852. <Card className="mt-6">
  4853. <CardHeader>
  4854. <h3 className="text-base font-semibold text-white" id="card-webhooks">{t('settings.webhookEndpoints')}</h3>
  4855. </CardHeader>
  4856. <CardContent className="space-y-3 text-sm">
  4857. <p className="text-bambu-gray">
  4858. {t('settings.webhookApiKeyHint')}
  4859. </p>
  4860. <div className="space-y-2 font-mono text-xs">
  4861. <div className="p-2 bg-bambu-dark rounded">
  4862. <span className="text-green-700 dark:text-green-400">POST</span>{' '}
  4863. <span className="text-white">/api/v1/webhook/queue/add</span>
  4864. <span className="text-bambu-gray"> - {t('settings.webhook.addToQueue')}</span>
  4865. </div>
  4866. <div className="p-2 bg-bambu-dark rounded">
  4867. <span className="text-orange-700 dark:text-orange-400">POST</span>{' '}
  4868. <span className="text-white">/api/v1/webhook/printer/:id/start</span>
  4869. <span className="text-bambu-gray"> - {t('settings.webhook.startNext')}</span>
  4870. </div>
  4871. <div className="p-2 bg-bambu-dark rounded">
  4872. <span className="text-red-700 dark:text-red-400">POST</span>{' '}
  4873. <span className="text-white">/api/v1/webhook/printer/:id/stop</span>
  4874. <span className="text-bambu-gray"> - {t('settings.webhook.stopPrint')}</span>
  4875. </div>
  4876. <div className="p-2 bg-bambu-dark rounded">
  4877. <span className="text-red-700 dark:text-red-400">POST</span>{' '}
  4878. <span className="text-white">/api/v1/webhook/printer/:id/cancel</span>
  4879. <span className="text-bambu-gray"> - {t('settings.webhook.cancelPrint')}</span>
  4880. </div>
  4881. <div className="p-2 bg-bambu-dark rounded">
  4882. <span className="text-blue-700 dark:text-blue-400">GET</span>{' '}
  4883. <span className="text-white">/api/v1/webhook/printer/:id/status</span>
  4884. <span className="text-bambu-gray"> - {t('settings.webhook.getSpecificStatus')}</span>
  4885. </div>
  4886. <div className="p-2 bg-bambu-dark rounded">
  4887. <span className="text-blue-700 dark:text-blue-400">GET</span>{' '}
  4888. <span className="text-white">/api/v1/webhook/queue</span>
  4889. <span className="text-bambu-gray"> - {t('settings.webhook.queueStatus')}</span>
  4890. </div>
  4891. </div>
  4892. </CardContent>
  4893. </Card>
  4894. </>}
  4895. {/* Connected apps: "Sign in with Bambuddy" for external applications.
  4896. Admin-only, like the settings it sits between. */}
  4897. {/* Admin-only: an app's redirect address decides where an admin's sign-in goes. */}
  4898. {isAdmin && (
  4899. <Card className="mt-6">
  4900. <CardHeader>
  4901. <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-connected-apps">
  4902. <Link2 className="w-4 h-4 text-bambu-green" />
  4903. {t('connectedApps.title')}
  4904. </h3>
  4905. </CardHeader>
  4906. <CardContent>
  4907. <ConnectedAppsSection />
  4908. </CardContent>
  4909. </Card>
  4910. )}
  4911. </div>
  4912. {/* Right Column - API Browser. Hidden from users without
  4913. api_keys:read since the API Browser is the testing surface
  4914. for those keys. */}
  4915. {hasPermission('api_keys:read') && <div className="min-w-0">
  4916. <div className="mb-6">
  4917. <h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-apibrowser">
  4918. <Globe className="w-5 h-5 text-bambu-green" />
  4919. {t('settings.apiBrowser')}
  4920. </h2>
  4921. <p className="text-sm text-bambu-gray mt-1">
  4922. {t('settings.apiBrowserDescription')}
  4923. </p>
  4924. </div>
  4925. {/* API Key Input for Testing */}
  4926. <Card className="mb-4">
  4927. <CardContent className="py-3">
  4928. <label className="block text-sm text-bambu-gray mb-2">{t('settings.apiKeyForTesting')}</label>
  4929. <input
  4930. type="text"
  4931. value={testApiKey}
  4932. onChange={(e) => setTestApiKey(e.target.value)}
  4933. placeholder={t('settings.apiKeyPlaceholder')}
  4934. 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"
  4935. />
  4936. <p className="text-xs text-bambu-gray mt-2">
  4937. {t('settings.apiKeyHint')}
  4938. </p>
  4939. </CardContent>
  4940. </Card>
  4941. <APIBrowser apiKey={testApiKey} />
  4942. </div>}
  4943. </div>
  4944. )}
  4945. {/* Virtual Printer Tab */}
  4946. {activeTab === 'virtual-printer' && (
  4947. <div id="card-vp">
  4948. <VirtualPrinterList />
  4949. </div>
  4950. )}
  4951. {/* SpoolBuddy Tab */}
  4952. {activeTab === 'spoolbuddy' && (
  4953. <div id="card-spoolbuddy">
  4954. <SpoolBuddySettings />
  4955. </div>
  4956. )}
  4957. {/* Filament Tab */}
  4958. {/* Queue Tab — sub-tabs: Queue & Dispatch (current content) / Pipelines (#1425) */}
  4959. {activeTab === 'queue' && (
  4960. <div className="space-y-3">
  4961. {/* Sub-tab nav, mirroring the Authentication tab pattern */}
  4962. <div className="flex gap-1 border-b border-bambu-dark-tertiary">
  4963. <button
  4964. onClick={() => handleQueueSubTabChange('dispatch')}
  4965. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  4966. queueSubTab === 'dispatch'
  4967. ? 'text-bambu-green border-bambu-green'
  4968. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  4969. }`}
  4970. >
  4971. <ListOrdered className="w-4 h-4" />
  4972. {t('settings.tabs.queueDispatch', 'Queue & Dispatch')}
  4973. </button>
  4974. <button
  4975. onClick={() => handleQueueSubTabChange('pipelines')}
  4976. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  4977. queueSubTab === 'pipelines'
  4978. ? 'text-bambu-green border-bambu-green'
  4979. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  4980. }`}
  4981. >
  4982. <Workflow className="w-4 h-4" />
  4983. {t('settings.tabs.queuePipelines', 'Pipelines')}
  4984. </button>
  4985. </div>
  4986. {queueSubTab === 'pipelines' && <SlicerPipelinesPanel />}
  4987. {queueSubTab === 'dispatch' && localSettings && (
  4988. <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
  4989. {/* Left Column */}
  4990. <div className="lg:w-1/2 space-y-3">
  4991. {/* Default Print Options */}
  4992. <Card id="card-print-options">
  4993. <CardHeader>
  4994. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  4995. <ListOrdered className="w-4 h-4 text-bambu-green" />
  4996. {t('settings.defaultPrintOptions', 'Default Print Options')}
  4997. </h3>
  4998. </CardHeader>
  4999. <CardContent className="space-y-3">
  5000. <p className="text-xs text-bambu-gray">
  5001. {t('settings.defaultPrintOptionsDescription', 'Set default values for print options when starting new prints. These can be overridden per print in the print dialog.')}
  5002. </p>
  5003. {[
  5004. { key: 'default_bed_levelling' as const, label: t('settings.defaultBedLevelling', 'Bed Levelling'), desc: t('settings.defaultBedLevellingDesc', 'Auto-level bed before print'), fallback: true, dualNozzleOnly: false, tristate: true },
  5005. { key: 'default_flow_cali' as const, label: t('settings.defaultFlowCali', 'Flow Calibration'), desc: t('settings.defaultFlowCaliDesc', 'Calibrate extrusion flow'), fallback: false, dualNozzleOnly: false, tristate: true },
  5006. { key: 'default_vibration_cali' as const, label: t('settings.defaultVibrationCali', 'Vibration Calibration'), desc: t('settings.defaultVibrationCaliDesc', 'Reduce ringing artifacts'), fallback: true, dualNozzleOnly: false, tristate: false },
  5007. { key: 'default_layer_inspect' as const, label: t('settings.defaultLayerInspect', 'First Layer Inspection'), desc: t('settings.defaultLayerInspectDesc', 'AI inspection of first layer'), fallback: false, dualNozzleOnly: false, tristate: false },
  5008. { key: 'default_timelapse' as const, label: t('settings.defaultTimelapse', 'Timelapse'), desc: t('settings.defaultTimelapseDesc', 'Record timelapse video'), fallback: false, dualNozzleOnly: false, tristate: false },
  5009. { key: 'default_nozzle_offset_cali' as const, label: t('settings.defaultNozzleOffsetCali', 'Nozzle Offset Calibration'), desc: t('settings.defaultNozzleOffsetCaliDesc', 'Calibrate nozzle offsets between extruders'), fallback: true, dualNozzleOnly: true, tristate: true },
  5010. { key: 'default_confirm_outcome' as const, label: t('settings.defaultConfirmOutcome', 'Ask for Outcome'), desc: t('settings.defaultConfirmOutcomeDesc', 'Ask whether the print came out well after it completes'), fallback: false, dualNozzleOnly: false, tristate: false },
  5011. ]
  5012. .filter(({ dualNozzleOnly }) => !dualNozzleOnly || (printers || []).some(p => p.nozzle_count === 2))
  5013. .map(({ key, label, desc, fallback, tristate }) => (
  5014. <div key={key} className="flex items-center justify-between">
  5015. <div className="flex-1 mr-4">
  5016. <p className="text-sm text-white">{label}</p>
  5017. <p className="text-xs text-bambu-gray mt-0.5">{desc}</p>
  5018. </div>
  5019. {tristate ? (
  5020. <div className="flex gap-1 shrink-0">
  5021. {CALIBRATION_MODES.map((mode) => {
  5022. const current = (localSettings[key] as CalibrationMode | undefined) ?? 'auto';
  5023. return (
  5024. <button
  5025. key={mode}
  5026. type="button"
  5027. onClick={() => updateSetting(key, mode)}
  5028. className={`px-2.5 py-1 text-xs rounded transition-colors ${
  5029. current === mode
  5030. ? CALIBRATION_MODE_ACTIVE[mode]
  5031. : CALIBRATION_MODE_INACTIVE
  5032. }`}
  5033. >
  5034. {t(`settings.calibrationMode_${mode}`)}
  5035. </button>
  5036. );
  5037. })}
  5038. </div>
  5039. ) : (
  5040. <div className="flex gap-1 shrink-0">
  5041. {(['off', 'on'] as const).map((mode) => {
  5042. const current = ((localSettings[key] as boolean | undefined) ?? fallback) ? 'on' : 'off';
  5043. return (
  5044. <button
  5045. key={mode}
  5046. type="button"
  5047. onClick={() => updateSetting(key, mode === 'on')}
  5048. className={`px-2.5 py-1 text-xs rounded transition-colors ${
  5049. current === mode
  5050. ? CALIBRATION_MODE_ACTIVE[mode]
  5051. : CALIBRATION_MODE_INACTIVE
  5052. }`}
  5053. >
  5054. {t(`settings.calibrationMode_${mode}`)}
  5055. </button>
  5056. );
  5057. })}
  5058. </div>
  5059. )}
  5060. </div>
  5061. ))}
  5062. {/* Prints Bambuddy only archived, never dispatched, carry no
  5063. queue item to read the ask-for-outcome flag from (#1898). */}
  5064. <div className="flex items-center justify-between">
  5065. <div className="flex-1 mr-4">
  5066. <p className="text-sm text-white">
  5067. {t('settings.confirmOutcomeExternalPrints', 'Also ask for prints started outside Bambuddy')}
  5068. </p>
  5069. <p className="text-xs text-bambu-gray mt-0.5">
  5070. {t('settings.confirmOutcomeExternalPrintsDesc', 'Prints started at the printer, in Bambu Studio or in the Handy app are archived by Bambuddy too. With this on, they get the same outcome prompt as queued prints.')}
  5071. </p>
  5072. </div>
  5073. <label className="relative inline-flex items-center cursor-pointer shrink-0">
  5074. <input
  5075. type="checkbox"
  5076. checked={localSettings.confirm_outcome_external_prints ?? false}
  5077. onChange={(e) => updateSetting('confirm_outcome_external_prints', e.target.checked)}
  5078. className="sr-only peer"
  5079. />
  5080. <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>
  5081. </label>
  5082. </div>
  5083. </CardContent>
  5084. </Card>
  5085. {/* Plate-Clear Confirmation */}
  5086. <Card id="card-plate">
  5087. <CardHeader>
  5088. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5089. <Shield className="w-4 h-4 text-bambu-green" />
  5090. {t('settings.plateClear', 'Plate-Clear Confirmation')}
  5091. </h3>
  5092. </CardHeader>
  5093. <CardContent className="space-y-3">
  5094. <div className="flex items-center justify-between">
  5095. <div className="flex-1 mr-4">
  5096. <p className="text-sm text-white">
  5097. {t('settings.requirePlateClear', 'Require plate-clear confirmation')}
  5098. </p>
  5099. <p className="text-xs text-bambu-gray mt-1">
  5100. {t('settings.requirePlateClearDescription', 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.')}
  5101. </p>
  5102. </div>
  5103. <label className="relative inline-flex items-center cursor-pointer">
  5104. <input
  5105. type="checkbox"
  5106. checked={localSettings.require_plate_clear ?? false}
  5107. onChange={(e) => updateSetting('require_plate_clear', e.target.checked)}
  5108. className="sr-only peer"
  5109. />
  5110. <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>
  5111. </label>
  5112. </div>
  5113. {/* Post-print outcome confirmation (#1898): default unanswered
  5114. prompts to "good" the moment the plate is released. */}
  5115. <div className="flex items-center justify-between">
  5116. <div className="flex-1 mr-4">
  5117. <p className="text-sm text-white">
  5118. {t('settings.confirmDefaultGoodOnPlateClear', 'Count unanswered outcomes as good on plate release')}
  5119. </p>
  5120. <p className="text-xs text-bambu-gray mt-1">
  5121. {t('settings.confirmDefaultGoodOnPlateClearDescription', 'When the plate is released (manually or by the next queued print) and the print\'s outcome prompt is still unanswered, record it as a good part automatically. The print then counts as good as soon as the plate is released; a Telegram or link answer after that only shows the recorded result.')}
  5122. </p>
  5123. </div>
  5124. <label className="relative inline-flex items-center cursor-pointer">
  5125. <input
  5126. type="checkbox"
  5127. checked={localSettings.confirm_default_good_on_plate_clear ?? false}
  5128. onChange={(e) => updateSetting('confirm_default_good_on_plate_clear', e.target.checked)}
  5129. className="sr-only peer"
  5130. />
  5131. <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>
  5132. </label>
  5133. </div>
  5134. </CardContent>
  5135. </Card>
  5136. {/* Temperature & Fan Presets */}
  5137. <Card id="card-temp-fan-presets">
  5138. <CardHeader>
  5139. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5140. <Thermometer className="w-4 h-4 text-bambu-green" />
  5141. {t('settings.tempFanPresetsTitle', 'Temperature & Fan Presets')}
  5142. </h3>
  5143. </CardHeader>
  5144. <CardContent className="space-y-4">
  5145. <p className="text-xs text-bambu-gray">
  5146. {t('settings.tempFanPresetsDescription', 'Customize the quick-select values shown in printer-card temperature and fan-speed popovers. The Off button is always shown.')}
  5147. </p>
  5148. {PRESET_CATEGORIES.map(category => {
  5149. const raw = localSettings?.[category.key] ?? '';
  5150. const triple = parsePresetTriple(raw, category.defaults, category.lo, category.hi);
  5151. const unitLabel = category.unit === 'C' ? '°C' : '%';
  5152. const labelKeyMap = {
  5153. nozzle_temp_presets: 'tempFanPresetsNozzle',
  5154. bed_temp_presets: 'tempFanPresetsBed',
  5155. chamber_temp_presets: 'tempFanPresetsChamber',
  5156. fan_speed_presets: 'tempFanPresetsFan',
  5157. } as const;
  5158. const fallbackLabels = {
  5159. nozzle_temp_presets: 'Nozzle temperature',
  5160. bed_temp_presets: 'Bed temperature',
  5161. chamber_temp_presets: 'Chamber temperature',
  5162. fan_speed_presets: 'Fan speed',
  5163. } as const;
  5164. const rowLabel = t(`settings.${labelKeyMap[category.key]}`, fallbackLabels[category.key]);
  5165. return (
  5166. <div key={category.key}>
  5167. <div className="flex items-center justify-between mb-1">
  5168. <label className="text-sm text-white">
  5169. {rowLabel} <span className="text-bambu-gray text-xs">({unitLabel} · {category.lo}–{category.hi})</span>
  5170. </label>
  5171. <button
  5172. type="button"
  5173. onClick={() => updateSetting(category.key, '')}
  5174. title={t('settings.tempFanPresetsReset', 'Reset to defaults')}
  5175. className="text-bambu-gray hover:text-white transition-colors p-1 rounded hover:bg-bambu-dark-tertiary"
  5176. >
  5177. <RotateCcw className="w-3.5 h-3.5" />
  5178. </button>
  5179. </div>
  5180. <div className="flex gap-2">
  5181. {[0, 1, 2].map(idx => (
  5182. <input
  5183. key={idx}
  5184. type="number"
  5185. min={category.lo}
  5186. max={category.hi}
  5187. value={triple[idx]}
  5188. onChange={(e) => {
  5189. const next: [number, number, number] = [triple[0], triple[1], triple[2]];
  5190. const parsedValue = parseInt(e.target.value, 10);
  5191. const clamped = Math.max(category.lo, Math.min(category.hi, Number.isFinite(parsedValue) ? parsedValue : category.lo));
  5192. next[idx] = clamped;
  5193. updateSetting(category.key, JSON.stringify(next));
  5194. }}
  5195. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  5196. />
  5197. ))}
  5198. </div>
  5199. </div>
  5200. );
  5201. })}
  5202. </CardContent>
  5203. </Card>
  5204. {/* Staggered Batch Start */}
  5205. <Card id="card-staggered">
  5206. <CardHeader>
  5207. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5208. <Layers className="w-4 h-4 text-bambu-green" />
  5209. {t('settings.staggeredStart', 'Staggered Start')}
  5210. </h3>
  5211. </CardHeader>
  5212. <CardContent className="space-y-3">
  5213. <p className="text-xs text-bambu-gray">
  5214. {t('settings.staggeredStartDescription', 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.')}
  5215. </p>
  5216. <div className="flex gap-4">
  5217. <div className="flex-1">
  5218. <label className="block text-xs text-bambu-gray mb-1">
  5219. {t('settings.staggerGroupSize', 'Group size')}
  5220. </label>
  5221. <NumberInput
  5222. min={1}
  5223. max={50}
  5224. value={localSettings.stagger_group_size ?? 2}
  5225. onChange={(v) => updateSetting('stagger_group_size', v)}
  5226. fallback={1}
  5227. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  5228. />
  5229. <p className="text-xs text-bambu-gray mt-1">
  5230. {t('settings.staggerGroupSizeHelp', 'Printers to start simultaneously per group')}
  5231. </p>
  5232. </div>
  5233. <div className="flex-1">
  5234. <label className="block text-xs text-bambu-gray mb-1">
  5235. {t('settings.staggerInterval', 'Interval (minutes)')}
  5236. </label>
  5237. <NumberInput
  5238. min={1}
  5239. max={60}
  5240. value={localSettings.stagger_interval_minutes ?? 5}
  5241. onChange={(v) => updateSetting('stagger_interval_minutes', v)}
  5242. fallback={1}
  5243. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  5244. />
  5245. <p className="text-xs text-bambu-gray mt-1">
  5246. {t('settings.staggerIntervalHelp', 'Delay between each group starting')}
  5247. </p>
  5248. </div>
  5249. </div>
  5250. </CardContent>
  5251. </Card>
  5252. {/* Concurrent queue uploads (#2555) */}
  5253. <Card id="card-concurrent-uploads">
  5254. <CardHeader>
  5255. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5256. <UploadCloud className="w-4 h-4 text-bambu-green" />
  5257. {t('settings.concurrentUploadsTitle', 'Concurrent Uploads')}
  5258. </h3>
  5259. </CardHeader>
  5260. <CardContent className="space-y-3">
  5261. <p className="text-xs text-bambu-gray">
  5262. {t('settings.concurrentUploadsDescription', 'How many printers the queue may send files to at the same time. Printers receive files slowly (a large print can take several minutes), and each one waits its turn — so on a bigger fleet, raising this is what stops the last printer in a batch from waiting out every transfer before it. Lower it if your network or Bambuddy host struggles with parallel transfers.')}
  5263. </p>
  5264. <div className="w-full sm:w-1/2">
  5265. <label className="block text-xs text-bambu-gray mb-1">
  5266. {t('settings.concurrentUploadsLabel', 'Printers uploaded to at once')}
  5267. </label>
  5268. <NumberInput
  5269. min={1}
  5270. max={16}
  5271. value={localSettings.queue_max_concurrent_uploads ?? 4}
  5272. onChange={(v) => updateSetting('queue_max_concurrent_uploads', v)}
  5273. fallback={1}
  5274. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
  5275. />
  5276. <p className="text-xs text-bambu-gray mt-1">
  5277. {t('settings.concurrentUploadsHelp', '1 sends to one printer at a time (the old behaviour). Default is 4.')}
  5278. </p>
  5279. </div>
  5280. </CardContent>
  5281. </Card>
  5282. {/* Preheat & Heat Soak (#1468) */}
  5283. <Card id="card-preheat">
  5284. <CardHeader>
  5285. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5286. <Flame className="w-4 h-4 text-amber-600 dark:text-amber-400" />
  5287. {t('settings.preheatTitle', 'Preheat & Heat Soak')}
  5288. </h3>
  5289. </CardHeader>
  5290. <CardContent className="space-y-3">
  5291. <p className="text-xs text-bambu-gray">
  5292. {t('settings.preheatDescription', 'Heat the bed (and chamber, if supported) and hold at temperature before each queued print starts. Helpful for engineering filaments (PA, ABS) on printers without an active chamber heater — the bed warms the chamber by radiation while the soak timer runs. The bed target is read from the print file; chamber behaviour depends on printer model.')}
  5293. </p>
  5294. <div className="flex items-center justify-between">
  5295. <div className="flex-1 mr-4">
  5296. <p className="text-sm text-white">
  5297. {t('settings.preheatEnabled', 'Enable preheat & soak')}
  5298. </p>
  5299. <p className="text-xs text-bambu-gray mt-0.5">
  5300. {t('settings.preheatEnabledDesc', 'When off, queued prints dispatch immediately.')}
  5301. </p>
  5302. </div>
  5303. <label className="relative inline-flex items-center cursor-pointer">
  5304. <input
  5305. type="checkbox"
  5306. checked={localSettings.preheat_enabled ?? false}
  5307. onChange={(e) => updateSetting('preheat_enabled', e.target.checked)}
  5308. className="sr-only peer"
  5309. />
  5310. <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>
  5311. </label>
  5312. </div>
  5313. <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
  5314. <div>
  5315. <label className="block text-xs text-bambu-gray mb-1">
  5316. {t('settings.preheatMaxWait', 'Max wait (seconds)')}
  5317. </label>
  5318. <NumberInput
  5319. min={60}
  5320. max={3600}
  5321. value={localSettings.preheat_max_wait_seconds ?? 900}
  5322. onChange={(v) => updateSetting('preheat_max_wait_seconds', v)}
  5323. fallback={900}
  5324. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
  5325. disabled={!(localSettings.preheat_enabled ?? false)}
  5326. />
  5327. <p className="text-xs text-bambu-gray mt-1">
  5328. {t('settings.preheatMaxWaitHelp', 'Cap on the chamber warm-up phase before falling through.')}
  5329. </p>
  5330. </div>
  5331. <div>
  5332. <label className="block text-xs text-bambu-gray mb-1">
  5333. {t('settings.preheatSoak', 'Soak (seconds)')}
  5334. </label>
  5335. <NumberInput
  5336. min={0}
  5337. max={1800}
  5338. value={localSettings.preheat_soak_seconds ?? 300}
  5339. onChange={(v) => updateSetting('preheat_soak_seconds', v)}
  5340. fallback={0}
  5341. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
  5342. disabled={!(localSettings.preheat_enabled ?? false)}
  5343. />
  5344. <p className="text-xs text-bambu-gray mt-1">
  5345. {t('settings.preheatSoakHelp', 'Hold time after target reached or max-wait elapsed.')}
  5346. </p>
  5347. </div>
  5348. </div>
  5349. {/* Keep bed warm between consecutive queue prints */}
  5350. <div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary/50">
  5351. <div className="flex-1 mr-4">
  5352. <p className="text-sm text-white">
  5353. {t('settings.keepBedWarm', 'Keep bed warm between prints')}
  5354. </p>
  5355. <p className="text-xs text-bambu-gray mt-0.5">
  5356. {t('settings.keepBedWarmDesc', 'While awaiting plate-clear, hold the bed at the keep-warm temperature below so the chamber stays hot — or at the next print\'s own bed temperature when that is higher. Only applies when the next print needs chamber heating (ASA, ABS, PA, PC etc.). Requires plate-clear confirmation to be enabled.')}
  5357. </p>
  5358. </div>
  5359. <label className="relative inline-flex items-center cursor-pointer">
  5360. <input
  5361. type="checkbox"
  5362. checked={localSettings.queue_keep_bed_warm ?? false}
  5363. onChange={(e) => updateSetting('queue_keep_bed_warm', e.target.checked)}
  5364. className="sr-only peer"
  5365. disabled={!(localSettings.preheat_enabled ?? false) || !(localSettings.require_plate_clear ?? false)}
  5366. />
  5367. <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 peer-disabled:opacity-50 peer-disabled:cursor-not-allowed"></div>
  5368. </label>
  5369. </div>
  5370. <div className="grid grid-cols-2 gap-3 mt-2">
  5371. <div>
  5372. <label className="block text-xs text-bambu-gray mb-1">
  5373. {t('settings.keepWarmBedTemp', 'Keep-warm bed temperature (°C)')}
  5374. </label>
  5375. <NumberInput
  5376. min={40}
  5377. max={110}
  5378. value={localSettings.queue_keep_warm_bed_temp ?? 90}
  5379. onChange={(v) => updateSetting('queue_keep_warm_bed_temp', v)}
  5380. fallback={90}
  5381. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
  5382. disabled={!(localSettings.preheat_enabled ?? false)}
  5383. />
  5384. <p className="text-xs text-bambu-gray mt-1">
  5385. {t('settings.keepWarmBedTempHelp', 'Used whenever the bed\'s job is to heat the chamber — the keep-warm hold, and preheat when the print file carries no bed temperature. A higher bed temperature from the print file always wins.')}
  5386. </p>
  5387. </div>
  5388. <div>
  5389. <label className="block text-xs text-bambu-gray mb-1">
  5390. {t('settings.keepWarmMaxMinutes', 'Stop keeping warm after (minutes)')}
  5391. </label>
  5392. <NumberInput
  5393. min={5}
  5394. max={480}
  5395. value={localSettings.queue_keep_warm_max_minutes ?? 120}
  5396. onChange={(v) => updateSetting('queue_keep_warm_max_minutes', v)}
  5397. fallback={120}
  5398. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
  5399. disabled={!(localSettings.queue_keep_bed_warm ?? false) || !(localSettings.preheat_enabled ?? false) || !(localSettings.require_plate_clear ?? false)}
  5400. />
  5401. <p className="text-xs text-bambu-gray mt-1">
  5402. {t('settings.keepWarmMaxMinutesHelp', 'If the plate is not cleared within this time, the heaters are switched off rather than held indefinitely.')}
  5403. </p>
  5404. </div>
  5405. </div>
  5406. {/* Per-filament chamber target editor (#1468) */}
  5407. <div className="pt-2 border-t border-bambu-dark-tertiary/50">
  5408. <div className="flex items-center justify-between mb-1">
  5409. <label className="text-sm text-white">
  5410. {t('settings.preheatFilamentTargetsLabel', 'Per-filament chamber target (°C)')}
  5411. </label>
  5412. <button
  5413. type="button"
  5414. onClick={() => updateSetting('preheat_filament_targets', '')}
  5415. title={t('settings.preheatFilamentTargetsReset', 'Reset to defaults')}
  5416. className="text-bambu-gray hover:text-white transition-colors p-1 rounded hover:bg-bambu-dark-tertiary"
  5417. >
  5418. <RotateCcw className="w-3.5 h-3.5" />
  5419. </button>
  5420. </div>
  5421. <p className="text-xs text-bambu-gray mb-2">
  5422. {t('settings.preheatFilamentTargetsHint', 'Bambuddy picks the highest target across the loaded AMS slots; PLA-only prints derive 0 and skip the chamber phase automatically.')}
  5423. </p>
  5424. <PreheatFilamentTargetsEditor
  5425. value={localSettings.preheat_filament_targets ?? ''}
  5426. onChange={(v) => updateSetting('preheat_filament_targets', v)}
  5427. disabled={!(localSettings.preheat_enabled ?? false)}
  5428. />
  5429. </div>
  5430. <p className="text-xs text-bambu-gray pt-1 border-t border-bambu-dark-tertiary/50">
  5431. <span className="font-medium text-bambu-gray/90">{t('settings.preheatHardwareTitle', 'Per-printer behaviour:')}</span>{' '}
  5432. {t('settings.preheatHardwareDetail', 'H2C/H2D/H2D Pro/H2S/X2D/X1E actively heat the chamber via M141. X1C/P2S read chamber temp but rely on bed-radiation heating. P1S/P1P/A1/A1 Mini have no chamber sensor — only the soak timer applies.')}
  5433. </p>
  5434. </CardContent>
  5435. </Card>
  5436. </div>
  5437. {/* Right Column */}
  5438. <div className="lg:w-1/2 space-y-3">
  5439. {/* Slicer Pipelines (#1425 PR C). Cap on the copies input in
  5440. the Run-with-pipeline modal to prevent fat-fingered queue
  5441. floods. Hard ceiling at 1000 enforced server-side. */}
  5442. <Card id="card-pipelines">
  5443. <CardHeader>
  5444. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5445. <Workflow className="w-4 h-4 text-bambu-green" />
  5446. {t('settings.pipelineLimits.title', 'Slicer Pipeline limits')}
  5447. </h3>
  5448. </CardHeader>
  5449. <CardContent className="space-y-2">
  5450. <div className="flex items-center justify-between gap-3">
  5451. <div className="flex-1">
  5452. <p className="text-sm text-white">
  5453. {t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
  5454. </p>
  5455. <p className="text-xs text-bambu-gray mt-0.5">
  5456. {t(
  5457. 'settings.pipelineLimits.maxCopiesDesc',
  5458. 'Upper bound on the copies operators can request when running a pipeline. Server-side hard cap is 1000.',
  5459. )}
  5460. </p>
  5461. </div>
  5462. <input
  5463. type="number"
  5464. min={1}
  5465. max={1000}
  5466. value={localSettings.pipeline_max_copies ?? 50}
  5467. onChange={(e) => {
  5468. const n = parseInt(e.target.value, 10);
  5469. if (Number.isNaN(n)) return;
  5470. updateSetting('pipeline_max_copies', Math.max(1, Math.min(1000, n)));
  5471. }}
  5472. aria-label={t('settings.pipelineLimits.maxCopiesLabel', 'Max copies per run')}
  5473. className="w-24 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
  5474. />
  5475. </div>
  5476. </CardContent>
  5477. </Card>
  5478. {/* Slicer */}
  5479. <Card id="card-slicer">
  5480. <CardHeader>
  5481. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5482. <Cog className="w-4 h-4 text-bambu-green" />
  5483. {t('settings.slicerCard', 'Slicer')}
  5484. </h3>
  5485. </CardHeader>
  5486. <CardContent className="space-y-3">
  5487. {/* Where slicing runs. Rendered only once more than one engine
  5488. is actually usable — while the sidecar is the only one, a
  5489. picker with a single entry is noise, and an entry the user
  5490. can see but never select reads as a broken feature. Adding a
  5491. browser engine to lib/sliceEngines.ts makes this appear. */}
  5492. {hasEngineChoice() && (
  5493. <div>
  5494. <label className="block text-sm text-bambu-gray mb-1">
  5495. {t('settings.sliceEngine')}
  5496. </label>
  5497. <div className="relative">
  5498. <select
  5499. value={resolveEngine(localSettings.slice_engine)}
  5500. onChange={(e) => updateSetting('slice_engine', e.target.value as SliceEngineId)}
  5501. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  5502. >
  5503. {availableEngines().map((engine) => (
  5504. <option key={engine.id} value={engine.id}>
  5505. {t(engine.labelKey)}
  5506. </option>
  5507. ))}
  5508. </select>
  5509. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  5510. </div>
  5511. <p className="text-xs text-bambu-gray mt-1">
  5512. {t(
  5513. availableEngines().find((e) => e.id === resolveEngine(localSettings.slice_engine))?.descriptionKey
  5514. ?? 'settings.sliceEngineSidecarHint',
  5515. )}
  5516. </p>
  5517. </div>
  5518. )}
  5519. <div>
  5520. <label className="block text-sm text-bambu-gray mb-1">
  5521. {t('settings.preferredSlicer')}
  5522. </label>
  5523. <div className="relative">
  5524. <select
  5525. value={localSettings.preferred_slicer ?? 'bambu_studio'}
  5526. onChange={(e) => updateSetting('preferred_slicer', e.target.value as 'bambu_studio' | 'orcaslicer')}
  5527. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  5528. >
  5529. <option value="bambu_studio">{t('settings.slicerBambuStudio')}</option>
  5530. <option value="orcaslicer">{t('settings.slicerOrcaSlicer')}</option>
  5531. </select>
  5532. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  5533. </div>
  5534. <p className="text-xs text-bambu-gray mt-1">
  5535. {t('settings.preferredSlicerDescription')}
  5536. </p>
  5537. {/* Upstream OrcaSlicer 2.3.2 / 2.4.0-dev have two known
  5538. CLI bugs that block slicing many Bambu-authored 3MFs:
  5539. a SIGSEGV on painted multi-extruder 3MFs (#12426) and
  5540. a strict range-check on sentinel parameter values
  5541. BambuStudio writes by default. Until the upstream
  5542. fixes land, surface a clear warning when a user has
  5543. OrcaSlicer selected so they know what to expect; we
  5544. don't auto-switch them in case they're testing. */}
  5545. {(localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer' && (
  5546. <div
  5547. role="alert"
  5548. className="text-xs text-amber-800 dark:text-amber-200 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700/40 rounded p-2 mt-2"
  5549. >
  5550. {t(
  5551. 'settings.orcaslicerKnownIssuesWarning',
  5552. 'OrcaSlicer 2.3.2 / 2.4.0-dev have known CLI bugs that block slicing many Bambu-authored 3MFs — see upstream issues #12426 (segfault on painted multi-extruder files) and #13386 (parameter-range strict-validation reject). Bambu Studio is recommended until the upstream fixes land.',
  5553. )}
  5554. </div>
  5555. )}
  5556. </div>
  5557. {/* Desktop "Open in Slicer" override (#1329). Independent of the
  5558. API slicer so a user can slice via the Bambu Studio sidecar
  5559. but open files locally in OrcaSlicer, or vice versa. */}
  5560. <div>
  5561. <label className="block text-sm text-bambu-gray mb-1">
  5562. {t('settings.openInSlicerLabel', 'Open in Slicer')}
  5563. </label>
  5564. <div className="relative">
  5565. <select
  5566. value={localSettings.open_in_slicer ?? ''}
  5567. onChange={(e) =>
  5568. updateSetting(
  5569. 'open_in_slicer',
  5570. e.target.value === '' ? null : (e.target.value as 'bambu_studio' | 'orcaslicer'),
  5571. )
  5572. }
  5573. className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  5574. >
  5575. <option value="">{t('settings.openInSlicerInherit', 'Same as API slicer')}</option>
  5576. <option value="bambu_studio">{t('settings.slicerBambuStudio')}</option>
  5577. <option value="orcaslicer">{t('settings.slicerOrcaSlicer')}</option>
  5578. </select>
  5579. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  5580. </div>
  5581. <p className="text-xs text-bambu-gray mt-1">
  5582. {t(
  5583. 'settings.openInSlicerDescription',
  5584. "Desktop slicer used by the 'Open in Slicer' button. Leave on 'Same as API slicer' to inherit, or pick a different slicer to use locally.",
  5585. )}
  5586. </p>
  5587. </div>
  5588. <div className="flex items-center justify-between gap-3">
  5589. <div className="min-w-0">
  5590. <p className="text-white">{t('settings.useSlicerApi')}</p>
  5591. <p className="text-sm text-bambu-gray">
  5592. {t('settings.useSlicerApiDescription')}
  5593. </p>
  5594. </div>
  5595. <label className="flex items-center cursor-pointer shrink-0">
  5596. <input
  5597. type="checkbox"
  5598. checked={localSettings.use_slicer_api ?? false}
  5599. onChange={(e) => updateSetting('use_slicer_api', e.target.checked)}
  5600. className="sr-only peer"
  5601. />
  5602. <div className="relative w-11 h-6 bg-bambu-dark-tertiary rounded-full peer peer-checked:bg-bambu-green peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-bambu-green/50 transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-5"></div>
  5603. </label>
  5604. </div>
  5605. {(localSettings.use_slicer_api ?? false) && (
  5606. <div>
  5607. <label className="block text-sm text-bambu-gray mb-1">
  5608. {(localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
  5609. ? t('settings.orcaslicerApiUrl', 'OrcaSlicer sidecar URL')
  5610. : t('settings.bambuStudioApiUrl', 'Bambu Studio sidecar URL')}
  5611. </label>
  5612. <input
  5613. type="text"
  5614. value={
  5615. ((localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
  5616. ? localSettings.orcaslicer_api_url
  5617. : localSettings.bambu_studio_api_url) ?? ''
  5618. }
  5619. onChange={(e) =>
  5620. updateSetting(
  5621. (localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
  5622. ? 'orcaslicer_api_url'
  5623. : 'bambu_studio_api_url',
  5624. e.target.value,
  5625. )
  5626. }
  5627. placeholder={
  5628. (localSettings.preferred_slicer ?? 'bambu_studio') === 'orcaslicer'
  5629. ? 'http://localhost:3003'
  5630. : 'http://localhost:3001'
  5631. }
  5632. 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 placeholder:text-bambu-gray/40"
  5633. />
  5634. <p className="text-xs text-bambu-gray mt-1">
  5635. {t(
  5636. 'settings.slicerApiUrlDescription',
  5637. 'URL of the slicer-API sidecar container. Leave blank to use the SLICER_API_URL / BAMBU_STUDIO_API_URL env var defaults.',
  5638. )}
  5639. </p>
  5640. </div>
  5641. )}
  5642. {(localSettings.use_slicer_api ?? false) && (
  5643. <div>
  5644. <label className="block text-sm text-bambu-gray mb-1">
  5645. {t('settings.slicerStallTimeout')}
  5646. </label>
  5647. <input
  5648. type="number"
  5649. min={1}
  5650. max={240}
  5651. value={localSettings.slicer_stall_timeout_minutes ?? 15}
  5652. onChange={(e) =>
  5653. updateSetting('slicer_stall_timeout_minutes', Number(e.target.value))
  5654. }
  5655. 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"
  5656. />
  5657. <p className="text-xs text-bambu-gray mt-1">
  5658. {t('settings.slicerStallTimeoutDescription')}
  5659. </p>
  5660. </div>
  5661. )}
  5662. </CardContent>
  5663. </Card>
  5664. {/* Auto-Drying */}
  5665. <Card>
  5666. <CardHeader>
  5667. <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-drying">
  5668. <Flame className="w-4 h-4 text-amber-600 dark:text-amber-400" />
  5669. {t('settings.queueDrying')}
  5670. </h3>
  5671. </CardHeader>
  5672. <CardContent className="space-y-3">
  5673. <p className="text-xs text-bambu-gray">
  5674. {t('settings.queueDryingDescription')}
  5675. </p>
  5676. <div className="flex items-center justify-between">
  5677. <div>
  5678. <label className="block text-sm text-white">
  5679. {t('settings.queueDryingEnabled')}
  5680. </label>
  5681. <p className="text-xs text-bambu-gray mt-0.5">
  5682. {t('settings.queueDryingEnabledDescription')}
  5683. </p>
  5684. </div>
  5685. <label className="relative inline-flex items-center cursor-pointer">
  5686. <input
  5687. type="checkbox"
  5688. checked={localSettings.queue_drying_enabled ?? false}
  5689. onChange={(e) => updateSetting('queue_drying_enabled', e.target.checked)}
  5690. className="sr-only peer"
  5691. />
  5692. <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>
  5693. </label>
  5694. </div>
  5695. {localSettings.queue_drying_enabled && (
  5696. <div className="flex items-center justify-between">
  5697. <div>
  5698. <label className="block text-sm text-white">
  5699. {t('settings.queueDryingBlock')}
  5700. </label>
  5701. <p className="text-xs text-bambu-gray mt-0.5">
  5702. {t('settings.queueDryingBlockDescription')}
  5703. </p>
  5704. </div>
  5705. <label className="relative inline-flex items-center cursor-pointer">
  5706. <input
  5707. type="checkbox"
  5708. checked={localSettings.queue_drying_block ?? false}
  5709. onChange={(e) => updateSetting('queue_drying_block', e.target.checked)}
  5710. className="sr-only peer"
  5711. />
  5712. <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-amber-500"></div>
  5713. </label>
  5714. </div>
  5715. )}
  5716. <div className="flex items-center justify-between">
  5717. <div>
  5718. <label className="block text-sm text-white">
  5719. {t('settings.ambientDryingEnabled')}
  5720. </label>
  5721. <p className="text-xs text-bambu-gray mt-0.5">
  5722. {t('settings.ambientDryingEnabledDescription')}
  5723. </p>
  5724. </div>
  5725. <label className="relative inline-flex items-center cursor-pointer">
  5726. <input
  5727. type="checkbox"
  5728. checked={localSettings.ambient_drying_enabled ?? false}
  5729. onChange={(e) => {
  5730. setSustainedDraft(null);
  5731. updateSetting('ambient_drying_enabled', e.target.checked);
  5732. }}
  5733. className="sr-only peer"
  5734. />
  5735. <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>
  5736. </label>
  5737. </div>
  5738. {localSettings.ambient_drying_enabled && (
  5739. <div className="flex items-center justify-between">
  5740. <div>
  5741. <label className="block text-sm text-white">
  5742. {t('settings.ambientDryingSustainedEnabled')}
  5743. </label>
  5744. <p className="text-xs text-bambu-gray mt-0.5">
  5745. {t('settings.ambientDryingSustainedDescription')}
  5746. </p>
  5747. </div>
  5748. <label className="relative inline-flex items-center cursor-pointer">
  5749. <input
  5750. type="checkbox"
  5751. checked={(localSettings.ambient_drying_sustained_minutes ?? 0) > 0}
  5752. onChange={(e) => {
  5753. setSustainedDraft(null);
  5754. updateSetting('ambient_drying_sustained_minutes', e.target.checked ? 15 : 0);
  5755. }}
  5756. className="sr-only peer"
  5757. />
  5758. <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>
  5759. </label>
  5760. </div>
  5761. )}
  5762. {localSettings.ambient_drying_enabled && (localSettings.ambient_drying_sustained_minutes ?? 0) > 0 && (
  5763. <div>
  5764. <label className="block text-sm text-bambu-gray mb-1">
  5765. {t('settings.ambientDryingSustainedMinutes')}
  5766. </label>
  5767. <input
  5768. type="number"
  5769. min="5"
  5770. max="240"
  5771. value={sustainedDraft ?? String(localSettings.ambient_drying_sustained_minutes ?? 15)}
  5772. onChange={(e) => setSustainedDraft(e.target.value)}
  5773. onBlur={(e) => {
  5774. const parsed = parseInt(e.target.value, 10);
  5775. if (!Number.isNaN(parsed)) {
  5776. updateSetting('ambient_drying_sustained_minutes', Math.max(5, Math.min(240, parsed)));
  5777. }
  5778. setSustainedDraft(null);
  5779. }}
  5780. onKeyDown={(e) => {
  5781. if (e.key === 'Enter') {
  5782. (e.currentTarget as HTMLInputElement).blur();
  5783. }
  5784. }}
  5785. 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"
  5786. />
  5787. </div>
  5788. )}
  5789. <div className="flex items-center justify-between">
  5790. <div>
  5791. <label className="block text-sm text-white">
  5792. {t('settings.printDryingEnabled')}
  5793. </label>
  5794. <p className="text-xs text-bambu-gray mt-0.5">
  5795. {t('settings.printDryingEnabledDescription')}
  5796. </p>
  5797. </div>
  5798. <label className="relative inline-flex items-center cursor-pointer">
  5799. <input
  5800. type="checkbox"
  5801. checked={localSettings.print_drying_enabled ?? false}
  5802. onChange={(e) => updateSetting('print_drying_enabled', e.target.checked)}
  5803. className="sr-only peer"
  5804. />
  5805. <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>
  5806. </label>
  5807. </div>
  5808. {/* Drying Presets Table */}
  5809. <div className="space-y-2">
  5810. <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
  5811. <p className="text-xs text-bambu-gray">{t('settings.dryingPresetsDescription')}</p>
  5812. <div className="overflow-x-auto">
  5813. <table className="w-full text-xs">
  5814. <thead>
  5815. <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
  5816. <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
  5817. <th className="text-center py-1.5" colSpan={2}>AMS 2 Pro</th>
  5818. <th className="text-center py-1.5" colSpan={2}>AMS-HT</th>
  5819. </tr>
  5820. </thead>
  5821. <tbody>
  5822. {(() => {
  5823. const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
  5824. PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
  5825. PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
  5826. TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
  5827. ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
  5828. ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
  5829. PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
  5830. PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
  5831. PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
  5832. };
  5833. let presets = { ...defaults };
  5834. try {
  5835. if (localSettings.drying_presets) {
  5836. const parsed = JSON.parse(localSettings.drying_presets);
  5837. if (typeof parsed === 'object' && parsed !== null) {
  5838. presets = { ...defaults, ...parsed };
  5839. }
  5840. }
  5841. } catch { /* use defaults */ }
  5842. const updatePreset = (fil: string, key: string, value: number) => {
  5843. const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };
  5844. updateSetting('drying_presets', JSON.stringify(updated));
  5845. };
  5846. return Object.entries(presets).map(([fil, preset]) => (
  5847. <tr key={fil} className="border-b border-bambu-dark-tertiary/50">
  5848. <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
  5849. <td className="py-1 px-1">
  5850. <div className="flex items-center justify-end gap-1">
  5851. <NumberInput min={30} max={65} value={preset.n3f}
  5852. onChange={v => updatePreset(fil, 'n3f', v)}
  5853. className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
  5854. />
  5855. <span className="text-bambu-gray">°C</span>
  5856. </div>
  5857. </td>
  5858. <td className="py-1 px-1">
  5859. <div className="flex items-center gap-1">
  5860. <NumberInput min={1} max={24} value={preset.n3f_hours}
  5861. onChange={v => updatePreset(fil, 'n3f_hours', v)}
  5862. className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
  5863. />
  5864. <span className="text-bambu-gray">h</span>
  5865. </div>
  5866. </td>
  5867. <td className="py-1 px-1">
  5868. <div className="flex items-center justify-end gap-1">
  5869. <NumberInput min={30} max={85} value={preset.n3s}
  5870. onChange={v => updatePreset(fil, 'n3s', v)}
  5871. className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
  5872. />
  5873. <span className="text-bambu-gray">°C</span>
  5874. </div>
  5875. </td>
  5876. <td className="py-1 px-1">
  5877. <div className="flex items-center gap-1">
  5878. <NumberInput min={1} max={24} value={preset.n3s_hours}
  5879. onChange={v => updatePreset(fil, 'n3s_hours', v)}
  5880. className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
  5881. />
  5882. <span className="text-bambu-gray">h</span>
  5883. </div>
  5884. </td>
  5885. </tr>
  5886. ));
  5887. })()}
  5888. </tbody>
  5889. </table>
  5890. </div>
  5891. </div>
  5892. {/* Per-Filament Humidity Thresholds (#1605) */}
  5893. <div className="space-y-2">
  5894. <p className="text-sm text-white font-medium">{t('settings.humidityThresholds')}</p>
  5895. <p className="text-xs text-bambu-gray">{t('settings.humidityThresholdsDescription')}</p>
  5896. <div className="overflow-x-auto">
  5897. <table className="w-full text-xs">
  5898. <thead>
  5899. <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
  5900. <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
  5901. <th className="text-right py-1.5 pr-2">{t('settings.humidityThresholdCol')}</th>
  5902. </tr>
  5903. </thead>
  5904. <tbody>
  5905. {(() => {
  5906. const defaultFair = localSettings.ams_humidity_fair ?? 60;
  5907. const filamentTypes = ['PLA', 'PETG', 'TPU', 'ABS', 'ASA', 'PA', 'PC', 'PVA'];
  5908. let thresholds: Record<string, number> = {};
  5909. try {
  5910. if (localSettings.ams_humidity_thresholds) {
  5911. const parsed = JSON.parse(localSettings.ams_humidity_thresholds);
  5912. if (typeof parsed === 'object' && parsed !== null) {
  5913. thresholds = parsed;
  5914. }
  5915. }
  5916. } catch { /* invalid → empty */ }
  5917. const rows: Array<{ key: string; label: string; value: number; isDefault: boolean }> = [
  5918. {
  5919. key: 'default',
  5920. label: t('settings.humidityThresholdDefault'),
  5921. value: Number(thresholds.default ?? defaultFair),
  5922. isDefault: true,
  5923. },
  5924. ...filamentTypes.map((fil) => ({
  5925. key: fil,
  5926. label: fil,
  5927. value: Number(thresholds[fil] ?? thresholds.default ?? defaultFair),
  5928. isDefault: false,
  5929. })),
  5930. ];
  5931. const commitThreshold = (key: string, raw: string) => {
  5932. // Empty / blank → drop the override, falling back to
  5933. // the default (or to ams_humidity_fair for the
  5934. // default row itself).
  5935. if (raw.trim() === '') {
  5936. const next = { ...thresholds };
  5937. delete next[key];
  5938. updateSetting('ams_humidity_thresholds', JSON.stringify(next));
  5939. return;
  5940. }
  5941. const parsed = parseInt(raw, 10);
  5942. if (Number.isNaN(parsed)) {
  5943. return;
  5944. }
  5945. const clamped = Math.max(5, Math.min(95, parsed));
  5946. const next = { ...thresholds, [key]: clamped };
  5947. updateSetting('ams_humidity_thresholds', JSON.stringify(next));
  5948. };
  5949. return rows.map((row) => {
  5950. // Show the draft string if the user is mid-edit;
  5951. // otherwise fall through to the resolved row value.
  5952. const draft = humidityDrafts[row.key];
  5953. const displayValue = draft !== undefined ? draft : String(row.value);
  5954. return (
  5955. <tr key={row.key} className="border-b border-bambu-dark-tertiary/50">
  5956. <td className={`py-1.5 pr-2 font-medium ${row.isDefault ? 'text-bambu-gray italic' : 'text-white'}`}>{row.label}</td>
  5957. <td className="py-1 pr-2">
  5958. <div className="flex items-center justify-end gap-1">
  5959. <input
  5960. type="number"
  5961. min={5}
  5962. max={95}
  5963. value={displayValue}
  5964. onChange={(e) => setHumidityDrafts((prev) => ({ ...prev, [row.key]: e.target.value }))}
  5965. onBlur={(e) => {
  5966. commitThreshold(row.key, e.target.value);
  5967. setHumidityDrafts((prev) => {
  5968. const next = { ...prev };
  5969. delete next[row.key];
  5970. return next;
  5971. });
  5972. }}
  5973. onKeyDown={(e) => {
  5974. if (e.key === 'Enter') {
  5975. (e.currentTarget as HTMLInputElement).blur();
  5976. }
  5977. }}
  5978. className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
  5979. />
  5980. <span className="text-bambu-gray">%</span>
  5981. </div>
  5982. </td>
  5983. </tr>
  5984. );
  5985. });
  5986. })()}
  5987. </tbody>
  5988. </table>
  5989. </div>
  5990. </div>
  5991. </CardContent>
  5992. </Card>
  5993. {/* G-code Injection (#422) */}
  5994. <Card id="card-gcode">
  5995. <CardHeader>
  5996. <h3 className="text-base font-semibold text-white flex items-center gap-2">
  5997. <Code className="w-4 h-4 text-bambu-green" />
  5998. {t('settings.gcodeInjection', 'G-code Injection')}
  5999. </h3>
  6000. </CardHeader>
  6001. <CardContent className="space-y-3">
  6002. <p className="text-xs text-bambu-gray">
  6003. {t('settings.gcodeInjectionDescription', 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.')}
  6004. </p>
  6005. {(() => {
  6006. const gcodeSnippets: Record<string, { start_gcode: string; end_gcode: string }> = (() => {
  6007. try {
  6008. return localSettings.gcode_snippets ? JSON.parse(localSettings.gcode_snippets) : {};
  6009. } catch {
  6010. return {};
  6011. }
  6012. })();
  6013. const printerModels = [...new Set((printers || []).filter((p) => p.model).map((p) => p.model as string))].sort();
  6014. const updateSnippet = (model: string, field: 'start_gcode' | 'end_gcode', value: string) => {
  6015. const updated = { ...gcodeSnippets };
  6016. if (!updated[model]) {
  6017. updated[model] = { start_gcode: '', end_gcode: '' };
  6018. }
  6019. updated[model][field] = value;
  6020. // Remove model entry if both fields are empty
  6021. if (!updated[model].start_gcode && !updated[model].end_gcode) {
  6022. delete updated[model];
  6023. }
  6024. const newValue = Object.keys(updated).length > 0 ? JSON.stringify(updated) : '';
  6025. // Update local state for immediate UI feedback, save on blur
  6026. setLocalSettings(prev => prev ? { ...prev, gcode_snippets: newValue } : null);
  6027. pendingGcodeSnippetsRef.current = newValue;
  6028. };
  6029. const saveGcodeSnippets = () => {
  6030. if (pendingGcodeSnippetsRef.current !== null) {
  6031. updateMutation.mutate({ gcode_snippets: pendingGcodeSnippetsRef.current });
  6032. pendingGcodeSnippetsRef.current = null;
  6033. }
  6034. };
  6035. if (printerModels.length === 0) {
  6036. return (
  6037. <p className="text-sm text-bambu-gray italic">
  6038. {t('settings.gcodeInjectionNoPrinters', 'No printers found. Add printers to configure G-code snippets.')}
  6039. </p>
  6040. );
  6041. }
  6042. return printerModels.map((model) => {
  6043. const snippet = gcodeSnippets[model] || { start_gcode: '', end_gcode: '' };
  6044. const hasContent = !!(snippet.start_gcode || snippet.end_gcode);
  6045. return (
  6046. <Collapsible
  6047. key={model}
  6048. defaultOpen={hasContent}
  6049. className="border border-bambu-dark-tertiary rounded-lg px-3 py-2"
  6050. summary={
  6051. <div className="flex items-center gap-2">
  6052. <h4 className="text-sm font-medium text-white">{model}</h4>
  6053. {hasContent && (
  6054. <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
  6055. {t('settings.gcodeConfigured', 'Configured')}
  6056. </span>
  6057. )}
  6058. </div>
  6059. }
  6060. >
  6061. <div className="space-y-2">
  6062. <div>
  6063. <label className="block text-xs text-bambu-gray mb-1">
  6064. {t('settings.gcodeStartLabel', 'Start G-code')}
  6065. </label>
  6066. <textarea
  6067. value={snippet.start_gcode}
  6068. onChange={(e) => updateSnippet(model, 'start_gcode', e.target.value)}
  6069. onBlur={saveGcodeSnippets}
  6070. placeholder={t('settings.gcodeStartPlaceholder', 'G-code prepended before the print starts...')}
  6071. rows={3}
  6072. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y"
  6073. />
  6074. </div>
  6075. <div>
  6076. <label className="block text-xs text-bambu-gray mb-1">
  6077. {t('settings.gcodeEndLabel', 'End G-code')}
  6078. </label>
  6079. <textarea
  6080. value={snippet.end_gcode}
  6081. onChange={(e) => updateSnippet(model, 'end_gcode', e.target.value)}
  6082. onBlur={saveGcodeSnippets}
  6083. placeholder={t('settings.gcodeEndPlaceholder', 'G-code appended after the print ends...')}
  6084. rows={3}
  6085. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y"
  6086. />
  6087. </div>
  6088. </div>
  6089. </Collapsible>
  6090. );
  6091. });
  6092. })()}
  6093. </CardContent>
  6094. </Card>
  6095. </div>
  6096. </div>
  6097. )}
  6098. </div>
  6099. )}
  6100. {activeTab === 'filament' && localSettings && (
  6101. <>
  6102. <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
  6103. {/* Left Column (1/3) - Mode Selector + AMS Thresholds */}
  6104. <div className="lg:w-1/3 space-y-3">
  6105. <SpoolmanSettings />
  6106. <Card id="card-filamentchecks">
  6107. <CardHeader>
  6108. <h2 className="text-lg font-semibold text-white">{t('settings.filamentChecks')}</h2>
  6109. </CardHeader>
  6110. <CardContent className="space-y-3">
  6111. <div className="flex items-center justify-between">
  6112. <div>
  6113. <p className="text-white">{t('settings.disableFilamentWarnings')}</p>
  6114. <p className="text-sm text-bambu-gray">
  6115. {t('settings.disableFilamentWarningsDesc')}
  6116. </p>
  6117. </div>
  6118. <label className="relative inline-flex items-center cursor-pointer">
  6119. <input
  6120. type="checkbox"
  6121. checked={localSettings.disable_filament_warnings}
  6122. onChange={(e) => updateSetting('disable_filament_warnings', e.target.checked)}
  6123. className="sr-only peer"
  6124. />
  6125. <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>
  6126. </label>
  6127. </div>
  6128. <div className="flex items-center justify-between">
  6129. <div>
  6130. <p className="text-white">{t('settings.preferLowestFilament')}</p>
  6131. <p className="text-sm text-bambu-gray">
  6132. {t('settings.preferLowestFilamentDesc')}
  6133. </p>
  6134. <p className="text-xs text-bambu-gray/70 mt-1">
  6135. {t('settings.preferLowestFilamentBackupNote')}
  6136. </p>
  6137. </div>
  6138. <label className="relative inline-flex items-center cursor-pointer">
  6139. <input
  6140. type="checkbox"
  6141. checked={localSettings.prefer_lowest_filament}
  6142. onChange={(e) => updateSetting('prefer_lowest_filament', e.target.checked)}
  6143. className="sr-only peer"
  6144. />
  6145. <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>
  6146. </label>
  6147. </div>
  6148. </CardContent>
  6149. </Card>
  6150. {/* Per-Printer Mapping Default */}
  6151. <Card id="card-printmodal">
  6152. <CardHeader>
  6153. <h2 className="text-lg font-semibold text-white">{t('settings.printModal')}</h2>
  6154. </CardHeader>
  6155. <CardContent className="space-y-3">
  6156. <div className="flex items-center justify-between">
  6157. <div>
  6158. <p className="text-white">{t('settings.expandCustomMapping')}</p>
  6159. <p className="text-sm text-bambu-gray">
  6160. {t('settings.expandCustomMappingDescription')}
  6161. </p>
  6162. </div>
  6163. <label className="relative inline-flex items-center cursor-pointer">
  6164. <input
  6165. type="checkbox"
  6166. checked={localSettings.per_printer_mapping_expanded ?? false}
  6167. onChange={(e) => updateSetting('per_printer_mapping_expanded', e.target.checked)}
  6168. className="sr-only peer"
  6169. />
  6170. <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>
  6171. </label>
  6172. </div>
  6173. </CardContent>
  6174. </Card>
  6175. <Card id="card-amsthresholds">
  6176. <CardHeader>
  6177. <h2 className="text-lg font-semibold text-white">{t('settings.amsDisplayThresholds')}</h2>
  6178. </CardHeader>
  6179. <CardContent className="space-y-3">
  6180. <p className="text-sm text-bambu-gray">
  6181. {t('settings.amsThresholdsDescription')}
  6182. </p>
  6183. {/* Humidity Thresholds */}
  6184. <div className="space-y-3">
  6185. <div className="flex items-center gap-2 text-white">
  6186. <Droplets className="w-4 h-4 text-blue-600 dark:text-blue-400" />
  6187. <span className="font-medium">{t('settings.humidity')}</span>
  6188. </div>
  6189. <div className="grid grid-cols-2 gap-3">
  6190. <div>
  6191. <label className="block text-sm text-bambu-gray mb-1">
  6192. {t('settings.goodGreen')} ≤
  6193. </label>
  6194. <div className="flex items-center gap-2">
  6195. <NumberInput
  6196. min={0}
  6197. max={100}
  6198. value={localSettings.ams_humidity_good ?? 40}
  6199. onChange={(v) => updateSetting('ams_humidity_good', v)}
  6200. fallback={40}
  6201. 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"
  6202. />
  6203. <span className="text-bambu-gray">%</span>
  6204. </div>
  6205. </div>
  6206. <div>
  6207. <label className="block text-sm text-bambu-gray mb-1">
  6208. {t('settings.fairOrange')} ≤
  6209. </label>
  6210. <div className="flex items-center gap-2">
  6211. <NumberInput
  6212. min={0}
  6213. max={100}
  6214. value={localSettings.ams_humidity_fair ?? 60}
  6215. onChange={(v) => updateSetting('ams_humidity_fair', v)}
  6216. fallback={60}
  6217. 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"
  6218. />
  6219. <span className="text-bambu-gray">%</span>
  6220. </div>
  6221. </div>
  6222. </div>
  6223. <p className="text-xs text-bambu-gray">
  6224. {t('settings.aboveFairBad')}
  6225. </p>
  6226. <p className="text-xs text-amber-700/80 dark:text-amber-400/70">
  6227. {t('settings.fairAlsoDryingThreshold')}
  6228. </p>
  6229. {/* An AMS reads 15-20% while its heater is running, well above
  6230. what the same unit reports once cold, so a drying threshold
  6231. inside that band can never be satisfied while drying (#2770).
  6232. Warn rather than clamp — the number is also the colour
  6233. threshold on the AMS card, where a low value is a legitimate
  6234. choice. */}
  6235. {(localSettings.ams_humidity_fair ?? 60) < HUMIDITY_DRYING_FLOOR && (
  6236. <p className="text-xs text-red-600 dark:text-red-400">
  6237. {t('settings.fairBelowDryingFloor', { floor: HUMIDITY_DRYING_FLOOR })}
  6238. </p>
  6239. )}
  6240. </div>
  6241. {/* Temperature Thresholds */}
  6242. <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
  6243. <div className="flex items-center gap-2 text-white">
  6244. <Thermometer className="w-4 h-4 text-orange-600 dark:text-orange-400" />
  6245. <span className="font-medium">{t('settings.temperature')}</span>
  6246. </div>
  6247. <div className="grid grid-cols-2 gap-3">
  6248. <div>
  6249. <label className="block text-sm text-bambu-gray mb-1">
  6250. {t('settings.goodBlue')} ≤
  6251. </label>
  6252. <div className="flex items-center gap-2">
  6253. <NumberInput
  6254. step="0.5"
  6255. min={0}
  6256. max={60}
  6257. value={localSettings.ams_temp_good ?? 28}
  6258. onChange={(v) => updateSetting('ams_temp_good', v)}
  6259. integer={false}
  6260. fallback={28}
  6261. 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"
  6262. />
  6263. <span className="text-bambu-gray">°C</span>
  6264. </div>
  6265. </div>
  6266. <div>
  6267. <label className="block text-sm text-bambu-gray mb-1">
  6268. {t('settings.fairOrange')} ≤
  6269. </label>
  6270. <div className="flex items-center gap-2">
  6271. <NumberInput
  6272. step="0.5"
  6273. min={0}
  6274. max={60}
  6275. value={localSettings.ams_temp_fair ?? 35}
  6276. onChange={(v) => updateSetting('ams_temp_fair', v)}
  6277. integer={false}
  6278. fallback={35}
  6279. 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"
  6280. />
  6281. <span className="text-bambu-gray">°C</span>
  6282. </div>
  6283. </div>
  6284. </div>
  6285. <p className="text-xs text-bambu-gray">
  6286. {t('settings.aboveFairHot')}
  6287. </p>
  6288. {/* Below the band's own help text, so that line still reads as
  6289. describing Fair rather than this field. The comparison lives
  6290. in the label string here, not appended as a symbol like the
  6291. two above -- most locales word it as "Alarm above", which
  6292. would read doubled next to a `>`. */}
  6293. <div>
  6294. <label className="block text-sm text-bambu-gray mb-1">
  6295. {t('settings.tempAlarmThreshold')}
  6296. </label>
  6297. <div className="flex items-center gap-2">
  6298. <input
  6299. type="number"
  6300. step="0.5"
  6301. min="0.5"
  6302. max="120"
  6303. value={localSettings.ams_temp_alarm ?? ''}
  6304. placeholder={String(localSettings.ams_temp_fair ?? 35)}
  6305. onChange={(e) => {
  6306. const raw = e.target.value.trim();
  6307. const parsed = parseFloat(raw);
  6308. updateSetting('ams_temp_alarm', raw === '' || Number.isNaN(parsed) ? null : parsed);
  6309. }}
  6310. 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"
  6311. />
  6312. <span className="text-bambu-gray">°C</span>
  6313. </div>
  6314. </div>
  6315. {/* Warn rather than clamp, for the same reason the humidity
  6316. floor above does: clamping a controlled input mid-keystroke
  6317. makes "0.5" untypeable, because the "0" would blank the
  6318. field before the ".5" arrives. The backend refuses a
  6319. non-positive threshold and falls back to Fair, so say so
  6320. instead of pretending min= stopped it (#2905). */}
  6321. {(localSettings.ams_temp_alarm ?? 1) <= 0 && (
  6322. <p className="text-xs text-red-600 dark:text-red-400">
  6323. {t('settings.tempAlarmMustBePositive')}
  6324. </p>
  6325. )}
  6326. <p className="text-xs text-amber-700/80 dark:text-amber-400/70">
  6327. {t('settings.tempAlarmSeparateFromBand')}
  6328. </p>
  6329. </div>
  6330. {/* History Retention */}
  6331. <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
  6332. <div className="flex items-center gap-2 text-white">
  6333. <Database className="w-4 h-4 text-purple-600 dark:text-purple-400" />
  6334. <span className="font-medium">{t('settings.historyRetention')}</span>
  6335. </div>
  6336. <div>
  6337. <label className="block text-sm text-bambu-gray mb-1">
  6338. {t('settings.keepSensorHistory')}
  6339. </label>
  6340. <div className="flex items-center gap-2">
  6341. <NumberInput
  6342. min={1}
  6343. max={365}
  6344. value={localSettings.ams_history_retention_days ?? 30}
  6345. onChange={(v) => updateSetting('ams_history_retention_days', v)}
  6346. fallback={30}
  6347. 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"
  6348. />
  6349. <span className="text-bambu-gray">{t('common.days')}</span>
  6350. </div>
  6351. </div>
  6352. <div>
  6353. <label htmlFor="printer-sensor-history-retention" className="block text-sm text-bambu-gray mb-1">
  6354. {t('settings.keepPrinterTemperatureHistory')}
  6355. </label>
  6356. <div className="flex items-center gap-2">
  6357. <NumberInput
  6358. id="printer-sensor-history-retention"
  6359. min={1}
  6360. max={365}
  6361. value={localSettings.printer_sensor_history_retention_days ?? 30}
  6362. onChange={(v) => updateSetting('printer_sensor_history_retention_days', v)}
  6363. fallback={30}
  6364. 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"
  6365. />
  6366. <span className="text-bambu-gray">{t('common.days')}</span>
  6367. </div>
  6368. </div>
  6369. <p className="text-xs text-bambu-gray">
  6370. {t('settings.historyRetentionDescription')}
  6371. </p>
  6372. </div>
  6373. </CardContent>
  6374. </Card>
  6375. </div>
  6376. {/* Right Column (2/3) - Spool Catalog + Color Catalog */}
  6377. <div className="lg:w-2/3 space-y-3">
  6378. <ProfileDefaultsSettings />
  6379. <SpoolCatalogSettings />
  6380. <ColorCatalogSettings />
  6381. </div>
  6382. </div>
  6383. </>
  6384. )}
  6385. {/* Delete API Key Confirmation */}
  6386. {showDeleteAPIKeyConfirm !== null && (
  6387. <ConfirmModal
  6388. title={t('settings.deleteApiKeyTitle')}
  6389. message={t('settings.deleteApiKeyMessage')}
  6390. confirmText={t('settings.deleteKey')}
  6391. variant="danger"
  6392. onConfirm={() => {
  6393. deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);
  6394. setShowDeleteAPIKeyConfirm(null);
  6395. }}
  6396. onCancel={() => setShowDeleteAPIKeyConfirm(null)}
  6397. />
  6398. )}
  6399. {/* Smart Plug Modal */}
  6400. {showPlugModal && (
  6401. <AddSmartPlugModal
  6402. plug={editingPlug}
  6403. onClose={() => {
  6404. setShowPlugModal(false);
  6405. setEditingPlug(null);
  6406. }}
  6407. />
  6408. )}
  6409. {/* Home Assistant Sensor Modal (#1148) */}
  6410. {showHASensorModal && (
  6411. <HASensorModal
  6412. sensor={editingHASensor}
  6413. printers={printers ?? []}
  6414. onClose={() => {
  6415. setShowHASensorModal(false);
  6416. setEditingHASensor(null);
  6417. }}
  6418. />
  6419. )}
  6420. {showLocationHASensorModal && (
  6421. <LocationHASensorModal
  6422. sensor={editingLocationHASensor}
  6423. locations={haSensorLocations ?? []}
  6424. onClose={() => {
  6425. setShowLocationHASensorModal(false);
  6426. setEditingLocationHASensor(null);
  6427. }}
  6428. />
  6429. )}
  6430. {showLocationSensorOptionsModal && (
  6431. <LocationSensorOptionsModal onClose={() => setShowLocationSensorOptionsModal(false)} />
  6432. )}
  6433. {deleteLocationSensorsTarget && (
  6434. <ConfirmModal
  6435. title={t('locationHaSensors.deleteAllConfirm.title')}
  6436. message={t('locationHaSensors.deleteAllConfirm.message', {
  6437. location: deleteLocationSensorsTarget.locationName,
  6438. count: deleteLocationSensorsTarget.sensorIds.length,
  6439. })}
  6440. variant="danger"
  6441. isLoading={deleteLocationSensorsMutation.isPending}
  6442. onConfirm={() => deleteLocationSensorsMutation.mutate(deleteLocationSensorsTarget.sensorIds)}
  6443. onCancel={() => setDeleteLocationSensorsTarget(null)}
  6444. />
  6445. )}
  6446. {/* Notification Modal */}
  6447. {showNotificationModal && (
  6448. <AddNotificationModal
  6449. provider={editingProvider}
  6450. onClose={() => {
  6451. setShowNotificationModal(false);
  6452. setEditingProvider(null);
  6453. }}
  6454. />
  6455. )}
  6456. {/* Template Editor Modal */}
  6457. {editingTemplate && (
  6458. <NotificationTemplateEditor
  6459. template={editingTemplate}
  6460. onClose={() => setEditingTemplate(null)}
  6461. />
  6462. )}
  6463. {/* Notification Log Viewer */}
  6464. {showLogViewer && (
  6465. <NotificationLogViewer
  6466. onClose={() => setShowLogViewer(false)}
  6467. />
  6468. )}
  6469. {/* Confirm Modal: Clear Notification Logs */}
  6470. {showClearLogsConfirm && (
  6471. <ConfirmModal
  6472. title={t('settings.clearNotificationLogs')}
  6473. message={t('settings.clearLogsMessage')}
  6474. confirmText={t('settings.clearLogs')}
  6475. variant="warning"
  6476. onConfirm={async () => {
  6477. setShowClearLogsConfirm(false);
  6478. try {
  6479. const result = await api.clearNotificationLogs(30);
  6480. showToast(result.message, 'success');
  6481. } catch {
  6482. showToast(t('settings.toast.clearLogsFailed'), 'error');
  6483. }
  6484. }}
  6485. onCancel={() => setShowClearLogsConfirm(false)}
  6486. />
  6487. )}
  6488. {/* Confirm Modal: Clear Local Storage */}
  6489. {showClearStorageConfirm && (
  6490. <ConfirmModal
  6491. title={t('settings.resetUiPreferences')}
  6492. message={t('settings.resetUiPreferencesMessage')}
  6493. confirmText={t('settings.resetPreferences')}
  6494. variant="default"
  6495. onConfirm={() => {
  6496. setShowClearStorageConfirm(false);
  6497. localStorage.clear();
  6498. showToast(t('settings.toast.uiPreferencesReset'), 'success');
  6499. setTimeout(() => window.location.reload(), 1000);
  6500. }}
  6501. onCancel={() => setShowClearStorageConfirm(false)}
  6502. />
  6503. )}
  6504. {/* Confirm Modal: Bulk Plug Action */}
  6505. {showBulkPlugConfirm && (
  6506. <ConfirmModal
  6507. title={`Turn All Plugs ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  6508. 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!' : ''}`}
  6509. confirmText={`Turn All ${showBulkPlugConfirm === 'on' ? 'On' : 'Off'}`}
  6510. variant={showBulkPlugConfirm === 'off' ? 'danger' : 'warning'}
  6511. onConfirm={() => {
  6512. const action = showBulkPlugConfirm;
  6513. setShowBulkPlugConfirm(null);
  6514. bulkPlugActionMutation.mutate(action);
  6515. }}
  6516. onCancel={() => setShowBulkPlugConfirm(null)}
  6517. />
  6518. )}
  6519. {/* Release Notes Modal */}
  6520. {showReleaseNotes && updateCheck?.release_notes && (
  6521. <div
  6522. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  6523. onClick={() => setShowReleaseNotes(false)}
  6524. >
  6525. <Card className="w-full max-w-2xl max-h-[80vh] flex flex-col" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  6526. <CardHeader className="flex flex-row items-center justify-between shrink-0">
  6527. <div>
  6528. <h2 className="text-lg font-semibold text-white">
  6529. Release Notes - v{updateCheck.latest_version}
  6530. </h2>
  6531. {updateCheck.release_name && updateCheck.release_name !== updateCheck.latest_version && (
  6532. <p className="text-sm text-bambu-gray">{updateCheck.release_name}</p>
  6533. )}
  6534. </div>
  6535. <button
  6536. onClick={() => setShowReleaseNotes(false)}
  6537. className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
  6538. >
  6539. <X className="w-5 h-5" />
  6540. </button>
  6541. </CardHeader>
  6542. <CardContent className="overflow-y-auto flex-1">
  6543. <pre className="text-sm text-bambu-gray whitespace-pre-wrap font-sans">
  6544. {updateCheck.release_notes}
  6545. </pre>
  6546. </CardContent>
  6547. <div className="p-4 border-t border-bambu-dark-tertiary shrink-0 flex gap-2">
  6548. {updateCheck.release_url && (
  6549. <a
  6550. href={updateCheck.release_url}
  6551. target="_blank"
  6552. rel="noopener noreferrer"
  6553. className="flex-1"
  6554. >
  6555. <Button variant="secondary" className="w-full">
  6556. <ExternalLink className="w-4 h-4" />
  6557. View on GitHub
  6558. </Button>
  6559. </a>
  6560. )}
  6561. <Button
  6562. onClick={() => setShowReleaseNotes(false)}
  6563. className="flex-1"
  6564. >
  6565. Close
  6566. </Button>
  6567. </div>
  6568. </Card>
  6569. </div>
  6570. )}
  6571. {/* Users Tab */}
  6572. {activeTab === 'users' && (
  6573. <div className="space-y-3">
  6574. {/* Sub-tab Navigation */}
  6575. <div className="flex gap-1 border-b border-bambu-dark-tertiary">
  6576. <button
  6577. onClick={() => selectUsersSubTab('users')}
  6578. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
  6579. shownUsersSubTab === 'users'
  6580. ? 'text-bambu-green border-bambu-green'
  6581. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6582. }`}
  6583. >
  6584. <Users className="w-4 h-4" />
  6585. {t('settings.tabs.users')}
  6586. </button>
  6587. {authEnabled && isAdmin && (
  6588. <button
  6589. onClick={() => selectUsersSubTab('printer-access')}
  6590. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  6591. shownUsersSubTab === 'printer-access'
  6592. ? 'text-bambu-green border-bambu-green'
  6593. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6594. }`}
  6595. >
  6596. <Printer className="w-4 h-4" />
  6597. {t('printerAccess.tab')}
  6598. </button>
  6599. )}
  6600. {isAdmin && (
  6601. <>
  6602. <button
  6603. onClick={() => selectUsersSubTab('email')}
  6604. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
  6605. shownUsersSubTab === 'email'
  6606. ? 'text-bambu-green border-bambu-green'
  6607. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6608. }`}
  6609. >
  6610. <Mail className="w-4 h-4" />
  6611. {t('settings.tabs.emailAuth') || 'Email Authentication'}
  6612. {advancedAuthStatus?.advanced_auth_enabled && (
  6613. <span className="w-2 h-2 rounded-full bg-green-400" />
  6614. )}
  6615. </button>
  6616. <button
  6617. onClick={() => selectUsersSubTab('ldap')}
  6618. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
  6619. shownUsersSubTab === 'ldap'
  6620. ? 'text-bambu-green border-bambu-green'
  6621. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6622. }`}
  6623. >
  6624. <Shield className="w-4 h-4" />
  6625. {t('settings.tabs.ldap') || 'LDAP'}
  6626. {ldapStatus?.ldap_enabled && (
  6627. <span className="w-2 h-2 rounded-full bg-green-400" />
  6628. )}
  6629. </button>
  6630. </>
  6631. )}
  6632. <button
  6633. onClick={() => selectUsersSubTab('twofa')}
  6634. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  6635. shownUsersSubTab === 'twofa'
  6636. ? 'text-bambu-green border-bambu-green'
  6637. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6638. }`}
  6639. >
  6640. <Shield className="w-4 h-4" />
  6641. {t('settings.tabs.twoFa')}
  6642. <span
  6643. className={`w-2 h-2 rounded-full ${
  6644. twoFAStatus?.totp_enabled || twoFAStatus?.email_otp_enabled
  6645. ? 'bg-green-400'
  6646. : 'bg-bambu-gray/40'
  6647. }`}
  6648. />
  6649. </button>
  6650. {isAdmin && (
  6651. <button
  6652. onClick={() => selectUsersSubTab('oidc')}
  6653. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  6654. shownUsersSubTab === 'oidc'
  6655. ? 'text-bambu-green border-bambu-green'
  6656. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6657. }`}
  6658. >
  6659. <Globe className="w-4 h-4" />
  6660. {t('settings.tabs.oidc')}
  6661. <span
  6662. className={`w-2 h-2 rounded-full ${
  6663. oidcProvidersAll.some((p) => p.is_enabled)
  6664. ? 'bg-green-400'
  6665. : 'bg-bambu-gray/40'
  6666. }`}
  6667. />
  6668. </button>
  6669. )}
  6670. {isAdmin && (
  6671. <button
  6672. onClick={() => selectUsersSubTab('security')}
  6673. className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
  6674. shownUsersSubTab === 'security'
  6675. ? 'text-bambu-green border-bambu-green'
  6676. : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
  6677. }`}
  6678. >
  6679. <Shield className="w-4 h-4" />
  6680. {t('settings.tabs.security')}
  6681. </button>
  6682. )}
  6683. </div>
  6684. {/* Users Sub-tab */}
  6685. {shownUsersSubTab === 'users' && (
  6686. <>
  6687. {/* Auth Toggle Header */}
  6688. <Card>
  6689. <CardContent className="py-4">
  6690. <div className="flex items-center justify-between">
  6691. <div className="flex items-center gap-3">
  6692. <div className={`w-10 h-10 rounded-full flex items-center justify-center ${authEnabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
  6693. {authEnabled ? (
  6694. <Lock className="w-5 h-5 text-green-600 dark:text-green-400" />
  6695. ) : (
  6696. <Unlock className="w-5 h-5 text-gray-400" />
  6697. )}
  6698. </div>
  6699. <div>
  6700. <h3 className="text-white font-medium">{t('settings.authentication')}</h3>
  6701. <p className="text-sm text-bambu-gray">
  6702. {authEnabled
  6703. ? t('settings.authEnabledDescription')
  6704. : t('settings.authDisabledDescription')}
  6705. </p>
  6706. </div>
  6707. </div>
  6708. {!authEnabled ? (
  6709. <Button onClick={() => navigate('/setup')}>
  6710. <Lock className="w-4 h-4" />
  6711. {t('common.enable')}
  6712. </Button>
  6713. ) : user?.is_admin && (
  6714. <Button variant="secondary" onClick={() => setShowDisableAuthConfirm(true)}>
  6715. <Unlock className="w-4 h-4" />
  6716. {t('common.disable')}
  6717. </Button>
  6718. )}
  6719. </div>
  6720. </CardContent>
  6721. </Card>
  6722. {/* Advanced Authentication Notice Box */}
  6723. {advancedAuthStatus?.advanced_auth_enabled && (
  6724. <Card className="border-blue-500/30 bg-blue-500/5">
  6725. <CardContent className="py-4">
  6726. <div className="flex items-start gap-3">
  6727. <div className="w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0">
  6728. <Mail className="w-5 h-5 text-blue-600 dark:text-blue-400" />
  6729. </div>
  6730. <div>
  6731. <h3 className="text-white font-medium">{t('settings.email.advancedAuthEnabled')}</h3>
  6732. <p className="text-sm text-bambu-gray mt-1">
  6733. {t('settings.email.advancedAuthEnabledDesc')}
  6734. </p>
  6735. </div>
  6736. </div>
  6737. </CardContent>
  6738. </Card>
  6739. )}
  6740. {authEnabled && (
  6741. <div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
  6742. {/* Left Column: Session Policy + Current User + User List */}
  6743. <div className="space-y-3">
  6744. {/* Session Policy (#1706) — admin-set ceiling for user session lifetime */}
  6745. <Card id="card-session-policy">
  6746. <CardHeader>
  6747. <h3 className="text-lg font-semibold text-white flex items-center gap-2">
  6748. <Lock className="w-5 h-5 text-bambu-green" />
  6749. {t('settings.sessionPolicy.title')}
  6750. </h3>
  6751. </CardHeader>
  6752. <CardContent>
  6753. <p className="text-sm text-bambu-gray mb-4">
  6754. {t('settings.sessionPolicy.description')}
  6755. {!isAdmin && <> {t('settings.adminOnlySetting')}</>}
  6756. </p>
  6757. <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
  6758. {[
  6759. { hours: 24, labelKey: 'settings.sessionPolicy.preset24h' },
  6760. { hours: 168, labelKey: 'settings.sessionPolicy.preset7d' },
  6761. { hours: 720, labelKey: 'settings.sessionPolicy.preset30d' },
  6762. ].map((preset) => {
  6763. const current = localSettings?.session_max_hours ?? 24;
  6764. const isActive = current === preset.hours;
  6765. return (
  6766. <button
  6767. key={preset.hours}
  6768. type="button"
  6769. onClick={() => updateSetting('session_max_hours', preset.hours)}
  6770. disabled={!isAdmin}
  6771. className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
  6772. isActive
  6773. ? 'bg-bambu-green text-white'
  6774. : 'bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark'
  6775. } disabled:opacity-50 disabled:cursor-not-allowed`}
  6776. >
  6777. {t(preset.labelKey)}
  6778. </button>
  6779. );
  6780. })}
  6781. <div className="flex items-center gap-1">
  6782. <input
  6783. type="number"
  6784. min={1}
  6785. max={720}
  6786. value={localSettings?.session_max_hours ?? 24}
  6787. onChange={(e) => {
  6788. const raw = parseInt(e.target.value, 10);
  6789. if (Number.isNaN(raw)) return;
  6790. updateSetting('session_max_hours', Math.max(1, Math.min(720, raw)));
  6791. }}
  6792. disabled={!isAdmin}
  6793. aria-label={t('settings.sessionPolicy.customHoursLabel')}
  6794. className="w-20 px-2 py-2 bg-bambu-dark-tertiary text-white text-sm rounded-lg border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none disabled:opacity-50"
  6795. />
  6796. <span className="text-sm text-bambu-gray">{t('settings.sessionPolicy.hoursSuffix')}</span>
  6797. </div>
  6798. </div>
  6799. {(localSettings?.session_max_hours ?? 24) > 24 && (
  6800. <div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
  6801. <AlertTriangle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
  6802. <p className="text-xs text-yellow-800 dark:text-yellow-200">
  6803. {t('settings.sessionPolicy.warning')}
  6804. </p>
  6805. </div>
  6806. )}
  6807. </CardContent>
  6808. </Card>
  6809. {/* Current User Card */}
  6810. {user && (
  6811. <Card>
  6812. <CardHeader>
  6813. <div className="flex items-center justify-between">
  6814. <h3 className="text-lg font-semibold text-white flex items-center gap-2" id="card-currentuser">
  6815. <Users className="w-5 h-5 text-bambu-green" />
  6816. {t('settings.currentUser')}
  6817. </h3>
  6818. {user.auth_source !== 'ldap' && (
  6819. <Button size="sm" variant="ghost" onClick={() => setShowChangePasswordModal(true)}>
  6820. <Key className="w-4 h-4" />
  6821. {t('settings.changePassword')}
  6822. </Button>
  6823. )}
  6824. </div>
  6825. </CardHeader>
  6826. <CardContent>
  6827. <div className="flex items-center justify-between">
  6828. <div>
  6829. <p className="text-white font-medium text-lg">{user.username}</p>
  6830. <div className="flex flex-wrap gap-1 mt-2">
  6831. {user.is_admin && (
  6832. <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300">
  6833. {t('settings.admin')}
  6834. </span>
  6835. )}
  6836. {user.groups?.map(group => (
  6837. <span
  6838. key={group.id}
  6839. className={`px-2 py-0.5 rounded-full text-xs font-medium ${
  6840. group.name === 'Administrators'
  6841. ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300'
  6842. : group.name === 'Operators'
  6843. ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
  6844. : group.name === 'Viewers'
  6845. ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300'
  6846. : 'bg-gray-500/20 text-gray-300'
  6847. }`}
  6848. >
  6849. {group.name}
  6850. </span>
  6851. ))}
  6852. </div>
  6853. </div>
  6854. </div>
  6855. </CardContent>
  6856. </Card>
  6857. )}
  6858. {/* User List */}
  6859. <Card>
  6860. <CardHeader>
  6861. <div className="flex items-center justify-between">
  6862. <h3 className="text-lg font-semibold text-white flex items-center gap-2" id="card-users">
  6863. <Users className="w-5 h-5 text-bambu-green" />
  6864. {t('settings.users')}
  6865. </h3>
  6866. {hasPermission('users:create') && (
  6867. <Button
  6868. size="sm"
  6869. onClick={() => {
  6870. setShowCreateUserModal(true);
  6871. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  6872. }}
  6873. >
  6874. <Plus className="w-4 h-4" />
  6875. {t('settings.addUser')}
  6876. </Button>
  6877. )}
  6878. </div>
  6879. </CardHeader>
  6880. <CardContent>
  6881. {usersLoading ? (
  6882. <div className="flex items-center justify-center py-8">
  6883. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  6884. </div>
  6885. ) : usersData.length === 0 ? (
  6886. <p className="text-center text-bambu-gray py-8">{t('settings.noUsersFound')}</p>
  6887. ) : (
  6888. <div className="divide-y divide-bambu-dark-tertiary">
  6889. {usersData.map((userItem) => (
  6890. <div key={userItem.id} className="py-3 flex items-center justify-between">
  6891. <div className="flex-1 min-w-0">
  6892. <p className="text-white font-medium truncate">{userItem.username}</p>
  6893. <div className="flex flex-wrap gap-1 mt-1">
  6894. {userItem.auth_source === 'ldap' && (
  6895. <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-300">
  6896. LDAP
  6897. </span>
  6898. )}
  6899. {userItem.is_admin && (
  6900. <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300">
  6901. {t('settings.admin')}
  6902. </span>
  6903. )}
  6904. {userItem.groups?.map(group => (
  6905. <span
  6906. key={group.id}
  6907. className={`px-2 py-0.5 rounded-full text-xs font-medium ${
  6908. group.name === 'Administrators'
  6909. ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300'
  6910. : group.name === 'Operators'
  6911. ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
  6912. : group.name === 'Viewers'
  6913. ? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300'
  6914. : 'bg-gray-500/20 text-gray-300'
  6915. }`}
  6916. >
  6917. {group.name}
  6918. </span>
  6919. ))}
  6920. </div>
  6921. </div>
  6922. <div className="flex items-center gap-1 ml-4">
  6923. {hasPermission('users:update') && (
  6924. <Button size="sm" variant="ghost" onClick={() => startEditUser(userItem)}>
  6925. <Edit2 className="w-4 h-4" />
  6926. </Button>
  6927. )}
  6928. {hasPermission('users:delete') && userItem.id !== user?.id && (
  6929. <Button size="sm" variant="ghost" onClick={() => handleDeleteUserClick(userItem.id)}>
  6930. <Trash2 className="w-4 h-4" />
  6931. </Button>
  6932. )}
  6933. </div>
  6934. </div>
  6935. ))}
  6936. </div>
  6937. )}
  6938. </CardContent>
  6939. </Card>
  6940. </div>
  6941. {/* Right Column: Groups */}
  6942. <div>
  6943. <Card>
  6944. <CardHeader>
  6945. <div className="flex items-center justify-between">
  6946. <h3 className="text-lg font-semibold text-white flex items-center gap-2" id="card-groups">
  6947. <Shield className="w-5 h-5 text-bambu-green" />
  6948. {t('settings.groups')}
  6949. </h3>
  6950. {hasPermission('groups:create') && (
  6951. <Button
  6952. size="sm"
  6953. onClick={() => navigate('/groups/new')}
  6954. >
  6955. <Plus className="w-4 h-4" />
  6956. {t('settings.addGroup')}
  6957. </Button>
  6958. )}
  6959. </div>
  6960. </CardHeader>
  6961. <CardContent>
  6962. {groupsLoading ? (
  6963. <div className="flex items-center justify-center py-8">
  6964. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  6965. </div>
  6966. ) : groupsData.length === 0 ? (
  6967. <p className="text-center text-bambu-gray py-8">{t('settings.noGroupsFound')}</p>
  6968. ) : (
  6969. <div className="divide-y divide-bambu-dark-tertiary">
  6970. {groupsData.map((group) => (
  6971. <div key={group.id} className="py-3">
  6972. <div className="flex items-center justify-between">
  6973. <div className="flex items-center gap-2">
  6974. <Shield
  6975. className={`w-4 h-4 ${
  6976. group.name === 'Administrators'
  6977. ? 'text-purple-600 dark:text-purple-400'
  6978. : group.name === 'Operators'
  6979. ? 'text-blue-600 dark:text-blue-400'
  6980. : group.name === 'Viewers'
  6981. ? 'text-green-600 dark:text-green-400'
  6982. : 'text-bambu-gray'
  6983. }`}
  6984. />
  6985. <span className="text-white font-medium">{group.name}</span>
  6986. {group.is_system && (
  6987. <span className="px-2 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
  6988. {t('settings.system')}
  6989. </span>
  6990. )}
  6991. </div>
  6992. <div className="flex items-center gap-1">
  6993. {hasPermission('groups:update') && (
  6994. <Button size="sm" variant="ghost" onClick={() => navigate(`/groups/${group.id}/edit`)}>
  6995. <Edit2 className="w-4 h-4" />
  6996. </Button>
  6997. )}
  6998. {hasPermission('groups:delete') && !group.is_system && (
  6999. <Button size="sm" variant="ghost" onClick={() => setDeleteGroupId(group.id)}>
  7000. <Trash2 className="w-4 h-4" />
  7001. </Button>
  7002. )}
  7003. </div>
  7004. </div>
  7005. <p className="text-sm text-bambu-gray mt-1 ml-6">
  7006. {group.description || t('settings.noDescription')}
  7007. </p>
  7008. <div className="flex items-center gap-4 mt-2 ml-6 text-xs text-bambu-gray">
  7009. <span>{t('settings.userCount', { count: group.user_count })}</span>
  7010. <span>{t('settings.permissionCount', { count: group.permissions.length })}</span>
  7011. </div>
  7012. </div>
  7013. ))}
  7014. </div>
  7015. )}
  7016. </CardContent>
  7017. </Card>
  7018. </div>
  7019. </div>
  7020. )}
  7021. {/* Auth Disabled Info */}
  7022. {!authEnabled && (
  7023. <Card>
  7024. <CardContent className="py-6">
  7025. <div className="text-center">
  7026. <Unlock className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
  7027. <h3 className="text-lg font-medium text-white mb-2">{t('settings.authDisabledTitle')}</h3>
  7028. <p className="text-sm text-bambu-gray mb-4 max-w-md mx-auto">
  7029. {t('settings.authDisabledMessage')}
  7030. </p>
  7031. <ul className="space-y-2 text-sm text-bambu-gray mb-6 text-left max-w-xs mx-auto">
  7032. <li className="flex items-start gap-2">
  7033. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  7034. <span>{t('settings.authDisabledFeature1')}</span>
  7035. </li>
  7036. <li className="flex items-start gap-2">
  7037. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  7038. <span>{t('settings.authDisabledFeature2')}</span>
  7039. </li>
  7040. <li className="flex items-start gap-2">
  7041. <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
  7042. <span>{t('settings.authDisabledFeature3')}</span>
  7043. </li>
  7044. </ul>
  7045. <Button onClick={() => navigate('/setup')}>
  7046. <Lock className="w-4 h-4" />
  7047. {t('settings.enableAuthentication')}
  7048. </Button>
  7049. </div>
  7050. </CardContent>
  7051. </Card>
  7052. )}
  7053. </>
  7054. )}
  7055. {shownUsersSubTab === 'printer-access' && <PrinterAccessSettings />}
  7056. {/* Email Auth Sub-tab */}
  7057. {shownUsersSubTab === 'email' && (
  7058. <div className="max-w-5xl" id="card-smtp">
  7059. <EmailSettings />
  7060. </div>
  7061. )}
  7062. {shownUsersSubTab === 'ldap' && (
  7063. <div className="max-w-5xl" id="card-ldap">
  7064. <LDAPSettings />
  7065. </div>
  7066. )}
  7067. {shownUsersSubTab === 'twofa' && (
  7068. <div className="max-w-2xl">
  7069. <TwoFactorSettings />
  7070. </div>
  7071. )}
  7072. {shownUsersSubTab === 'oidc' && isAdmin && (
  7073. <div className="max-w-3xl space-y-4">
  7074. <Card>
  7075. <CardContent className="space-y-3 p-4">
  7076. <label className="flex items-start gap-3 cursor-pointer">
  7077. <input
  7078. type="checkbox"
  7079. checked={localSettings.local_login_enabled === false}
  7080. onChange={(e) => updateSetting('local_login_enabled', !e.target.checked)}
  7081. className="mt-1 h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
  7082. />
  7083. <div>
  7084. <p className="text-sm font-medium text-white">{t('settings.localLogin.disable')}</p>
  7085. <p className="text-xs text-bambu-gray mt-0.5">{t('settings.localLogin.disableHint')}</p>
  7086. </div>
  7087. </label>
  7088. </CardContent>
  7089. </Card>
  7090. <OIDCProviderSettings />
  7091. </div>
  7092. )}
  7093. {shownUsersSubTab === 'security' && isAdmin && (
  7094. <div className="max-w-3xl">
  7095. <SecurityStatusCard />
  7096. </div>
  7097. )}
  7098. </div>
  7099. )}
  7100. {/* Create User Modal */}
  7101. {showCreateUserModal && !advancedAuthStatus?.advanced_auth_enabled && (
  7102. <div
  7103. className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
  7104. onClick={() => {
  7105. setShowCreateUserModal(false);
  7106. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7107. }}
  7108. >
  7109. <Card
  7110. className="w-full max-w-md"
  7111. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  7112. >
  7113. <CardHeader>
  7114. <div className="flex items-center justify-between">
  7115. <div className="flex items-center gap-2">
  7116. <Users className="w-5 h-5 text-bambu-green" />
  7117. <h2 className="text-lg font-semibold text-white">{t('settings.createUser')}</h2>
  7118. </div>
  7119. <Button
  7120. variant="ghost"
  7121. size="sm"
  7122. onClick={() => {
  7123. setShowCreateUserModal(false);
  7124. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7125. }}
  7126. >
  7127. <X className="w-5 h-5" />
  7128. </Button>
  7129. </div>
  7130. </CardHeader>
  7131. <CardContent>
  7132. {ldapStatus?.ldap_enabled && (
  7133. <div
  7134. className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
  7135. role="tablist"
  7136. aria-label={t('users.modal.tabsAriaLabel')}
  7137. >
  7138. <button
  7139. type="button"
  7140. role="tab"
  7141. aria-selected={createUserTab === 'local'}
  7142. onClick={() => setCreateUserTab('local')}
  7143. className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
  7144. createUserTab === 'local'
  7145. ? 'bg-bambu-green/15 text-bambu-green'
  7146. : 'text-bambu-gray hover:text-white'
  7147. }`}
  7148. >
  7149. {t('users.modal.localTab')}
  7150. </button>
  7151. <button
  7152. type="button"
  7153. role="tab"
  7154. aria-selected={createUserTab === 'ldap'}
  7155. onClick={() => setCreateUserTab('ldap')}
  7156. className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
  7157. createUserTab === 'ldap'
  7158. ? 'bg-bambu-green/15 text-bambu-green'
  7159. : 'text-bambu-gray hover:text-white'
  7160. }`}
  7161. >
  7162. {t('users.modal.ldapTab')}
  7163. </button>
  7164. </div>
  7165. )}
  7166. {createUserTab === 'ldap' && ldapStatus?.ldap_enabled ? (
  7167. <>
  7168. <LdapUserPicker
  7169. onSuccess={(user) => {
  7170. setShowCreateUserModal(false);
  7171. setCreateUserTab('local');
  7172. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7173. showToast(t('users.toast.ldapProvisioned', { username: user.username }));
  7174. }}
  7175. />
  7176. <div className="mt-6 flex justify-end">
  7177. <Button
  7178. variant="secondary"
  7179. onClick={() => {
  7180. setShowCreateUserModal(false);
  7181. setCreateUserTab('local');
  7182. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7183. }}
  7184. >
  7185. {t('common.cancel')}
  7186. </Button>
  7187. </div>
  7188. </>
  7189. ) : (
  7190. <>
  7191. <div className="space-y-3">
  7192. <div>
  7193. <label className="block text-sm font-medium text-white mb-2">{t('settings.username')}</label>
  7194. <input
  7195. type="text"
  7196. value={userFormData.username}
  7197. onChange={(e) => setUserFormData({ ...userFormData, username: e.target.value })}
  7198. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7199. placeholder={t('settings.enterUsername')}
  7200. autoComplete="username"
  7201. />
  7202. </div>
  7203. <div>
  7204. <label className="block text-sm font-medium text-white mb-2">{t('settings.password')}</label>
  7205. <input
  7206. type="password"
  7207. value={userFormData.password}
  7208. onChange={(e) => setUserFormData({ ...userFormData, password: e.target.value })}
  7209. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7210. placeholder={t('settings.enterPassword')}
  7211. autoComplete="new-password"
  7212. minLength={8}
  7213. />
  7214. <p className="text-bambu-gray text-xs mt-1">{t('settings.passwordRequirements')}</p>
  7215. </div>
  7216. <div>
  7217. <label className="block text-sm font-medium text-white mb-2">{t('settings.confirmPassword')}</label>
  7218. <input
  7219. type="password"
  7220. value={userFormData.confirmPassword}
  7221. onChange={(e) => setUserFormData({ ...userFormData, confirmPassword: e.target.value })}
  7222. className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
  7223. userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword
  7224. ? 'border-red-500'
  7225. : 'border-bambu-dark-tertiary'
  7226. }`}
  7227. placeholder={t('settings.confirmPasswordPlaceholder')}
  7228. autoComplete="new-password"
  7229. minLength={6}
  7230. />
  7231. {userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (
  7232. <p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('settings.passwordsDoNotMatch')}</p>
  7233. )}
  7234. </div>
  7235. <div>
  7236. <label className="block text-sm font-medium text-white mb-2">{t('settings.groups')}</label>
  7237. <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
  7238. {groupsData.map(group => (
  7239. <label
  7240. key={group.id}
  7241. className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
  7242. >
  7243. <input
  7244. type="checkbox"
  7245. checked={userFormData.group_ids.includes(group.id)}
  7246. onChange={() => toggleUserGroup(group.id)}
  7247. className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
  7248. />
  7249. <span className="text-sm text-white">{group.name}</span>
  7250. {group.is_system && (
  7251. <span className="text-xs text-yellow-700 dark:text-yellow-400">{t('settings.systemBadge')}</span>
  7252. )}
  7253. </label>
  7254. ))}
  7255. {groupsData.length === 0 && (
  7256. <p className="text-sm text-bambu-gray">{t('settings.noGroupsAvailable')}</p>
  7257. )}
  7258. </div>
  7259. </div>
  7260. </div>
  7261. <div className="mt-6 flex justify-end gap-3">
  7262. <Button
  7263. variant="secondary"
  7264. onClick={() => {
  7265. setShowCreateUserModal(false);
  7266. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7267. }}
  7268. >
  7269. {t('common.cancel')}
  7270. </Button>
  7271. <Button
  7272. onClick={handleCreateUser}
  7273. disabled={createUserMutation.isPending || !userFormData.username || !userFormData.password || userFormData.password !== userFormData.confirmPassword || checkPasswordComplexity(userFormData.password) !== null}
  7274. >
  7275. {createUserMutation.isPending ? (
  7276. <>
  7277. <Loader2 className="w-4 h-4 animate-spin" />
  7278. {t('settings.creating')}
  7279. </>
  7280. ) : (
  7281. <>
  7282. <Plus className="w-4 h-4" />
  7283. {t('settings.createUser')}
  7284. </>
  7285. )}
  7286. </Button>
  7287. </div>
  7288. </>
  7289. )}
  7290. </CardContent>
  7291. </Card>
  7292. </div>
  7293. )}
  7294. {/* Create User Modal - Advanced Authentication */}
  7295. {showCreateUserModal && advancedAuthStatus?.advanced_auth_enabled && (
  7296. <CreateUserAdvancedAuthModal
  7297. formData={userFormData}
  7298. setFormData={setUserFormData}
  7299. groups={groupsData}
  7300. onClose={() => {
  7301. setShowCreateUserModal(false);
  7302. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7303. }}
  7304. onCreate={handleCreateUser}
  7305. isCreating={createUserMutation.isPending}
  7306. isCreateButtonDisabled={createUserMutation.isPending || !userFormData.username || !userFormData.email}
  7307. ldapEnabled={ldapStatus?.ldap_enabled}
  7308. onLdapProvisioned={(user) => {
  7309. setShowCreateUserModal(false);
  7310. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7311. showToast(t('users.toast.ldapProvisioned', { username: user.username }));
  7312. }}
  7313. />
  7314. )}
  7315. {/* Edit User Modal */}
  7316. {showEditUserModal && editingUserId !== null && (
  7317. <div
  7318. className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
  7319. onClick={() => {
  7320. setShowEditUserModal(false);
  7321. setEditingUserId(null);
  7322. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7323. }}
  7324. >
  7325. <Card
  7326. className="w-full max-w-md"
  7327. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  7328. >
  7329. <CardHeader>
  7330. <div className="flex items-center justify-between">
  7331. <div className="flex items-center gap-2">
  7332. <Edit2 className="w-5 h-5 text-bambu-green" />
  7333. <h2 className="text-lg font-semibold text-white">{t('settings.editUser')}</h2>
  7334. </div>
  7335. <Button
  7336. variant="ghost"
  7337. size="sm"
  7338. onClick={() => {
  7339. setShowEditUserModal(false);
  7340. setEditingUserId(null);
  7341. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7342. }}
  7343. >
  7344. <X className="w-5 h-5" />
  7345. </Button>
  7346. </div>
  7347. </CardHeader>
  7348. <CardContent>
  7349. <div className="space-y-3">
  7350. {/* Username Field */}
  7351. <div>
  7352. <label className="block text-sm font-medium text-white mb-2">
  7353. {t('settings.username')} {advancedAuthStatus?.advanced_auth_enabled && <span className="text-red-700 dark:text-red-400">*</span>}
  7354. </label>
  7355. <input
  7356. type="text"
  7357. value={userFormData.username}
  7358. onChange={(e) => setUserFormData({ ...userFormData, username: e.target.value })}
  7359. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7360. placeholder={t('settings.enterUsername')}
  7361. autoComplete="username"
  7362. />
  7363. </div>
  7364. {/* Email Field */}
  7365. <div>
  7366. <label className="block text-sm font-medium text-white mb-2">
  7367. {t('users.form.email') || 'Email'} {advancedAuthStatus?.advanced_auth_enabled ? <span className="text-red-700 dark:text-red-400">*</span> : <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>}
  7368. </label>
  7369. <input
  7370. type="email"
  7371. value={userFormData.email}
  7372. onChange={(e) => setUserFormData({ ...userFormData, email: e.target.value })}
  7373. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7374. placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
  7375. required={advancedAuthStatus?.advanced_auth_enabled}
  7376. />
  7377. </div>
  7378. {/* Password Fields - only show when Advanced Auth is disabled */}
  7379. {!advancedAuthStatus?.advanced_auth_enabled && (
  7380. <>
  7381. <div>
  7382. <label className="block text-sm font-medium text-white mb-2">
  7383. {t('users.form.password') || 'Password'} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep') || 'leave blank to keep current'})</span>
  7384. </label>
  7385. <input
  7386. type="password"
  7387. value={userFormData.password}
  7388. onChange={(e) => setUserFormData({ ...userFormData, password: e.target.value, confirmPassword: '' })}
  7389. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7390. placeholder={t('settings.enterNewPassword')}
  7391. autoComplete="new-password"
  7392. minLength={8}
  7393. />
  7394. <p className="text-bambu-gray text-xs mt-1">{t('settings.passwordRequirements')}</p>
  7395. </div>
  7396. {userFormData.password && (
  7397. <div>
  7398. <label className="block text-sm font-medium text-white mb-2">{t('settings.confirmPassword')}</label>
  7399. <input
  7400. type="password"
  7401. value={userFormData.confirmPassword}
  7402. onChange={(e) => setUserFormData({ ...userFormData, confirmPassword: e.target.value })}
  7403. className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
  7404. userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword
  7405. ? 'border-red-500'
  7406. : 'border-bambu-dark-tertiary'
  7407. }`}
  7408. placeholder={t('settings.confirmNewPassword')}
  7409. autoComplete="new-password"
  7410. minLength={6}
  7411. />
  7412. {userFormData.confirmPassword && userFormData.password !== userFormData.confirmPassword && (
  7413. <p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('settings.passwordsDoNotMatch')}</p>
  7414. )}
  7415. </div>
  7416. )}
  7417. </>
  7418. )}
  7419. {/* Info box about auto-generated password when Advanced Auth is enabled */}
  7420. {advancedAuthStatus?.advanced_auth_enabled && (
  7421. <div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3 space-y-3">
  7422. <p className="text-sm text-bambu-gray">
  7423. {t('users.form.passwordManagedByAdvancedAuth') || 'Password is managed by Advanced Authentication. Use "Reset Password" to email the user a link to set a new password.'}
  7424. </p>
  7425. <Button
  7426. variant="secondary"
  7427. size="sm"
  7428. onClick={() => editingUserId && resetPasswordMutation.mutate(editingUserId)}
  7429. disabled={resetPasswordMutation.isPending || !userFormData.email}
  7430. className="w-full"
  7431. >
  7432. {resetPasswordMutation.isPending ? (
  7433. <>
  7434. <Loader2 className="w-4 h-4 animate-spin" />
  7435. {t('users.form.resettingPassword') || 'Resetting Password...'}
  7436. </>
  7437. ) : (
  7438. <>
  7439. <RotateCcw className="w-4 h-4" />
  7440. {t('users.form.resetPassword') || 'Reset Password'}
  7441. </>
  7442. )}
  7443. </Button>
  7444. </div>
  7445. )}
  7446. {/* Two-factor authentication: admin removal for a locked-out user */}
  7447. <AdminDisableTwoFactor
  7448. userId={editingUserId}
  7449. username={usersData.find(u => u.id === editingUserId)?.username ?? userFormData.username}
  7450. />
  7451. {/* Groups Field */}
  7452. <div>
  7453. <label className="block text-sm font-medium text-white mb-2">{t('users.form.groups') || 'Groups'}</label>
  7454. <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
  7455. {groupsData.map(group => (
  7456. <label
  7457. key={group.id}
  7458. className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
  7459. >
  7460. <input
  7461. type="checkbox"
  7462. checked={userFormData.group_ids.includes(group.id)}
  7463. onChange={() => toggleUserGroup(group.id)}
  7464. className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
  7465. />
  7466. <span className="text-sm text-white">{group.name}</span>
  7467. {group.is_system && (
  7468. <span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system') || 'System'})</span>
  7469. )}
  7470. </label>
  7471. ))}
  7472. </div>
  7473. </div>
  7474. </div>
  7475. <div className="mt-6 flex justify-end gap-3">
  7476. <Button
  7477. variant="secondary"
  7478. onClick={() => {
  7479. setShowEditUserModal(false);
  7480. setEditingUserId(null);
  7481. setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
  7482. }}
  7483. >
  7484. {t('users.modal.cancel') || 'Cancel'}
  7485. </Button>
  7486. <Button
  7487. onClick={() => handleUpdateUser(editingUserId)}
  7488. disabled={
  7489. updateUserMutation.isPending ||
  7490. !userFormData.username ||
  7491. (advancedAuthStatus?.advanced_auth_enabled && !userFormData.email) ||
  7492. Boolean(!advancedAuthStatus?.advanced_auth_enabled && userFormData.password && (userFormData.password !== userFormData.confirmPassword || checkPasswordComplexity(userFormData.password) !== null))
  7493. }
  7494. >
  7495. {updateUserMutation.isPending ? (
  7496. <>
  7497. <Loader2 className="w-4 h-4 animate-spin" />
  7498. {t('users.modal.saving') || 'Saving...'}
  7499. </>
  7500. ) : (
  7501. <>
  7502. <Save className="w-4 h-4" />
  7503. {t('users.modal.saveChanges') || 'Save Changes'}
  7504. </>
  7505. )}
  7506. </Button>
  7507. </div>
  7508. </CardContent>
  7509. </Card>
  7510. </div>
  7511. )}
  7512. {/* Delete User Confirmation Modal */}
  7513. {deleteUserId !== null && (
  7514. <div
  7515. className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
  7516. onClick={() => {
  7517. setDeleteUserId(null);
  7518. setDeleteUserItemCounts(null);
  7519. }}
  7520. >
  7521. <Card
  7522. className="w-full max-w-md"
  7523. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  7524. >
  7525. <CardHeader>
  7526. <div className="flex items-center gap-2 text-red-700 dark:text-red-400">
  7527. <Trash2 className="w-5 h-5" />
  7528. <h3 className="text-lg font-semibold">{t('settings.deleteUserTitle')}</h3>
  7529. </div>
  7530. </CardHeader>
  7531. <CardContent className="space-y-3">
  7532. {deleteUserLoading ? (
  7533. <div className="flex items-center justify-center py-4">
  7534. <div className="animate-spin rounded-full h-6 w-6 border-2 border-bambu-green border-t-transparent" />
  7535. </div>
  7536. ) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? (
  7537. <>
  7538. <p className="text-white">{t('settings.userHasCreated')}</p>
  7539. <ul className="list-disc list-inside text-bambu-gray space-y-1">
  7540. {deleteUserItemCounts.archives > 0 && (
  7541. <li>{deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}</li>
  7542. )}
  7543. {deleteUserItemCounts.queue_items > 0 && (
  7544. <li>{deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}</li>
  7545. )}
  7546. {deleteUserItemCounts.library_files > 0 && (
  7547. <li>{deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}</li>
  7548. )}
  7549. </ul>
  7550. <p className="text-bambu-gray text-sm">{t('settings.userItemsQuestion')}</p>
  7551. <div className="flex flex-col gap-2">
  7552. <Button
  7553. variant="danger"
  7554. onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: true })}
  7555. disabled={deleteUserMutation.isPending}
  7556. className="justify-center"
  7557. >
  7558. {t('settings.deleteUserAndItems')}
  7559. </Button>
  7560. <Button
  7561. variant="secondary"
  7562. onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}
  7563. disabled={deleteUserMutation.isPending}
  7564. className="justify-center"
  7565. >
  7566. {t('settings.deleteUserKeepItems')}
  7567. </Button>
  7568. <Button
  7569. variant="ghost"
  7570. onClick={() => {
  7571. setDeleteUserId(null);
  7572. setDeleteUserItemCounts(null);
  7573. }}
  7574. disabled={deleteUserMutation.isPending}
  7575. className="justify-center"
  7576. >
  7577. {t('common.cancel')}
  7578. </Button>
  7579. </div>
  7580. </>
  7581. ) : (
  7582. <>
  7583. <p className="text-white">{t('settings.deleteUserConfirm')}</p>
  7584. <p className="text-bambu-gray text-sm">{t('settings.actionCannotBeUndone')}</p>
  7585. <div className="flex gap-2 justify-end">
  7586. <Button
  7587. variant="ghost"
  7588. onClick={() => {
  7589. setDeleteUserId(null);
  7590. setDeleteUserItemCounts(null);
  7591. }}
  7592. disabled={deleteUserMutation.isPending}
  7593. >
  7594. {t('common.cancel')}
  7595. </Button>
  7596. <Button
  7597. variant="danger"
  7598. onClick={() => deleteUserMutation.mutate({ id: deleteUserId, deleteItems: false })}
  7599. disabled={deleteUserMutation.isPending}
  7600. >
  7601. {t('settings.deleteUserTitle')}
  7602. </Button>
  7603. </div>
  7604. </>
  7605. )}
  7606. </CardContent>
  7607. </Card>
  7608. </div>
  7609. )}
  7610. {/* Delete Group Confirmation Modal */}
  7611. {deleteGroupId !== null && (
  7612. <ConfirmModal
  7613. title={t('settings.deleteGroupTitle')}
  7614. message={t('settings.deleteGroupMessage')}
  7615. confirmText={t('settings.deleteGroup')}
  7616. variant="danger"
  7617. onConfirm={() => {
  7618. deleteGroupMutation.mutate(deleteGroupId);
  7619. setDeleteGroupId(null);
  7620. }}
  7621. onCancel={() => setDeleteGroupId(null)}
  7622. />
  7623. )}
  7624. {/* Backup Tab */}
  7625. {activeTab === 'failure-detection' && (
  7626. <div id="card-failure-detection">
  7627. <FailureDetectionSettings />
  7628. </div>
  7629. )}
  7630. {activeTab === 'backup' && (
  7631. <div id="card-backup">
  7632. <div className="mb-4 p-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-300 dark:border-amber-500/30 rounded-lg flex items-start gap-2">
  7633. <Shield className="text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" size={16} />
  7634. <p className="text-sm text-amber-700 dark:text-amber-400">{t('backup.includesEncryptionKey')}</p>
  7635. </div>
  7636. <GitHubBackupSettings />
  7637. </div>
  7638. )}
  7639. {/* Disable Authentication Confirmation Modal */}
  7640. {showDisableAuthConfirm && (
  7641. <ConfirmModal
  7642. title={t('settings.disableAuthenticationTitle')}
  7643. message={t('settings.disableAuthenticationMessage')}
  7644. confirmText={t('settings.disableAuthentication')}
  7645. variant="danger"
  7646. onConfirm={async () => {
  7647. try {
  7648. await api.disableAuth();
  7649. showToast(t('settings.toast.authDisabled'), 'success');
  7650. await refreshAuth();
  7651. setShowDisableAuthConfirm(false);
  7652. // Refresh the page to ensure all protected routes are accessible
  7653. window.location.href = '/';
  7654. } catch (error: unknown) {
  7655. const message = error instanceof Error ? error.message : t('settings.toast.authDisableFailed');
  7656. showToast(message, 'error');
  7657. }
  7658. }}
  7659. onCancel={() => setShowDisableAuthConfirm(false)}
  7660. />
  7661. )}
  7662. {/* Change Password Modal */}
  7663. {showChangePasswordModal && (
  7664. <div
  7665. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  7666. onClick={() => {
  7667. setShowChangePasswordModal(false);
  7668. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  7669. }}
  7670. >
  7671. <Card
  7672. className="w-full max-w-md"
  7673. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  7674. >
  7675. <CardHeader>
  7676. <div className="flex items-center justify-between">
  7677. <div className="flex items-center gap-2">
  7678. <Key className="w-5 h-5 text-bambu-green" />
  7679. <h2 className="text-lg font-semibold text-white">{t('settings.changePassword')}</h2>
  7680. </div>
  7681. <Button
  7682. variant="ghost"
  7683. size="sm"
  7684. onClick={() => {
  7685. setShowChangePasswordModal(false);
  7686. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  7687. }}
  7688. >
  7689. <X className="w-5 h-5" />
  7690. </Button>
  7691. </div>
  7692. </CardHeader>
  7693. <CardContent>
  7694. <div className="space-y-3">
  7695. <div>
  7696. <label className="block text-sm font-medium text-white mb-2">
  7697. Current Password
  7698. </label>
  7699. <input
  7700. type="password"
  7701. value={changePasswordData.currentPassword}
  7702. onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
  7703. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7704. placeholder={t('settings.enterCurrentPassword')}
  7705. autoComplete="current-password"
  7706. />
  7707. </div>
  7708. <div>
  7709. <label className="block text-sm font-medium text-white mb-2">
  7710. New Password
  7711. </label>
  7712. <input
  7713. type="password"
  7714. value={changePasswordData.newPassword}
  7715. onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
  7716. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  7717. placeholder={t('settings.enterNewPasswordMin6')}
  7718. autoComplete="new-password"
  7719. minLength={6}
  7720. />
  7721. </div>
  7722. <div>
  7723. <label className="block text-sm font-medium text-white mb-2">
  7724. Confirm New Password
  7725. </label>
  7726. <input
  7727. type="password"
  7728. value={changePasswordData.confirmPassword}
  7729. onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}
  7730. className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
  7731. changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword
  7732. ? 'border-red-500'
  7733. : 'border-bambu-dark-tertiary'
  7734. }`}
  7735. placeholder={t('settings.confirmNewPassword')}
  7736. autoComplete="new-password"
  7737. minLength={6}
  7738. />
  7739. {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
  7740. <p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('settings.passwordsDoNotMatch')}</p>
  7741. )}
  7742. </div>
  7743. </div>
  7744. <div className="mt-6 flex justify-end gap-3">
  7745. <Button
  7746. variant="secondary"
  7747. onClick={() => {
  7748. setShowChangePasswordModal(false);
  7749. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  7750. }}
  7751. >
  7752. {t('common.cancel')}
  7753. </Button>
  7754. <Button
  7755. onClick={async () => {
  7756. if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
  7757. showToast(t('settings.toast.passwordsDoNotMatch'), 'error');
  7758. return;
  7759. }
  7760. if (changePasswordData.newPassword.length < 6) {
  7761. showToast(t('settings.toast.passwordTooShort'), 'error');
  7762. return;
  7763. }
  7764. setChangePasswordLoading(true);
  7765. try {
  7766. await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
  7767. showToast(t('settings.toast.passwordChanged'), 'success');
  7768. setShowChangePasswordModal(false);
  7769. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  7770. } catch (error: unknown) {
  7771. const message = error instanceof Error ? error.message : 'Failed to change password';
  7772. showToast(message, 'error');
  7773. } finally {
  7774. setChangePasswordLoading(false);
  7775. }
  7776. }}
  7777. disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
  7778. >
  7779. {changePasswordLoading ? (
  7780. <>
  7781. <Loader2 className="w-4 h-4 animate-spin" />
  7782. {t('settings.changing')}
  7783. </>
  7784. ) : (
  7785. <>
  7786. <Key className="w-4 h-4" />
  7787. {t('settings.changePassword')}
  7788. </>
  7789. )}
  7790. </Button>
  7791. </div>
  7792. </CardContent>
  7793. </Card>
  7794. </div>
  7795. )}
  7796. </div>
  7797. </div>
  7798. </div>
  7799. </CardDensityProvider>
  7800. );
  7801. }