ProfilesPage.tsx 124 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985
  1. import { useState, useEffect, useMemo } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Cloud,
  6. LogIn,
  7. LogOut,
  8. Loader2,
  9. Settings2,
  10. Printer as PrinterIcon,
  11. Droplet,
  12. X,
  13. Key,
  14. RefreshCw,
  15. Gauge,
  16. Pencil,
  17. Trash2,
  18. Save,
  19. AlertTriangle,
  20. Search,
  21. Plus,
  22. Copy,
  23. Clock,
  24. Layers,
  25. Filter,
  26. ChevronDown,
  27. ArrowUp,
  28. Upload,
  29. Download,
  30. Sparkles,
  31. Check,
  32. AlertCircle,
  33. Code,
  34. Sliders,
  35. List,
  36. Eye,
  37. EyeOff,
  38. GitCompare,
  39. ArrowRight,
  40. Equal,
  41. Minus as MinusIcon,
  42. Plus as PlusIcon,
  43. HardDrive,
  44. } from 'lucide-react';
  45. import { api } from '../api/client';
  46. import { formatRelativeTime } from '../utils/date';
  47. import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
  48. import { Card, CardContent } from '../components/Card';
  49. import { Button } from '../components/Button';
  50. import { useToast } from '../contexts/ToastContext';
  51. import { useAuth } from '../contexts/AuthContext';
  52. import { KProfilesView } from '../components/KProfilesView';
  53. import { LocalProfilesView } from '../components/LocalProfilesView';
  54. type TFunction = (key: string, options?: Record<string, unknown>) => string;
  55. type ProfileTab = 'cloud' | 'local' | 'kprofiles';
  56. type LoginStep = 'email' | 'code' | 'token';
  57. type PresetType = 'all' | 'filament' | 'printer' | 'process';
  58. // Extract metadata from preset name or inherits field
  59. function extractMetadata(name: string, inherits?: string): {
  60. printer: string | null;
  61. nozzle: string | null;
  62. layerHeight: string | null;
  63. filamentType: string | null;
  64. } {
  65. const searchIn = `${name} ${inherits || ''}`;
  66. // Extract printer (e.g., "X1C", "P1S", "A1", "H2D")
  67. const printerMatch = searchIn.match(/@?\s*(?:BBL\s+)?(?:Bambu\s+Lab\s+)?([XPAH][1-9][A-Z]?(?:\s*(?:Carbon|mini))?|H2D)/i);
  68. const printer = printerMatch ? printerMatch[1].trim() : null;
  69. // Extract nozzle size (e.g., "0.4 nozzle", "0.6mm")
  70. const nozzleMatch = searchIn.match(/(\d+\.?\d*)\s*(?:mm\s*)?nozzle|nozzle\s*(\d+\.?\d*)/i);
  71. const nozzle = nozzleMatch ? (nozzleMatch[1] || nozzleMatch[2]) + 'mm' : null;
  72. // Extract layer height (e.g., "0.20mm", "0.08mm Extra Fine")
  73. const layerMatch = searchIn.match(/(\d+\.?\d*)mm\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i);
  74. const layerHeight = layerMatch ? layerMatch[1] + 'mm' : null;
  75. // Extract filament type (e.g., "PLA", "PETG", "ABS", "TPU")
  76. const filamentMatch = searchIn.match(/\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\b/i);
  77. const filamentType = filamentMatch ? filamentMatch[1].toUpperCase() : null;
  78. return { printer, nozzle, layerHeight, filamentType };
  79. }
  80. // Check if preset is user-created (editable)
  81. function isUserPreset(settingId: string): boolean {
  82. return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
  83. }
  84. // ============================================================================
  85. // LOGIN FORM
  86. // ============================================================================
  87. function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
  88. const { showToast } = useToast();
  89. const [step, setStep] = useState<LoginStep>('email');
  90. const [email, setEmail] = useState('');
  91. const [password, setPassword] = useState('');
  92. const [code, setCode] = useState('');
  93. const [token, setToken] = useState('');
  94. const [region, setRegion] = useState('global');
  95. const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);
  96. const [tfaKey, setTfaKey] = useState<string | null>(null);
  97. const loginMutation = useMutation({
  98. mutationFn: () => api.cloudLogin(email, password, region),
  99. onSuccess: (result) => {
  100. if (result.success) {
  101. showToast(t('profiles.login.toast.loggedIn'));
  102. onSuccess();
  103. } else if (result.needs_verification) {
  104. setVerificationType(result.verification_type || 'email');
  105. setTfaKey(result.tfa_key || null);
  106. if (result.verification_type === 'totp') {
  107. showToast(t('profiles.login.toast.enterTotp'));
  108. } else {
  109. showToast(t('profiles.login.toast.codeSent'));
  110. }
  111. setStep('code');
  112. } else {
  113. showToast(result.message, 'error');
  114. }
  115. },
  116. onError: (error: Error) => showToast(error.message, 'error'),
  117. });
  118. const verifyMutation = useMutation({
  119. mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined, region),
  120. onSuccess: (result) => {
  121. if (result.success) {
  122. showToast(t('profiles.login.toast.loggedIn'));
  123. onSuccess();
  124. } else {
  125. showToast(result.message, 'error');
  126. }
  127. },
  128. onError: (error: Error) => showToast(error.message, 'error'),
  129. });
  130. const tokenMutation = useMutation({
  131. mutationFn: () => api.cloudSetToken(token, region),
  132. onSuccess: () => {
  133. showToast(t('profiles.login.toast.tokenSet'));
  134. onSuccess();
  135. },
  136. onError: (error: Error) => showToast(error.message, 'error'),
  137. });
  138. const handleSubmit = (e: React.FormEvent) => {
  139. e.preventDefault();
  140. if (step === 'email') loginMutation.mutate();
  141. else if (step === 'code') verifyMutation.mutate();
  142. else if (step === 'token') tokenMutation.mutate();
  143. };
  144. const isPending = loginMutation.isPending || verifyMutation.isPending || tokenMutation.isPending;
  145. return (
  146. <Card className="max-w-md mx-auto">
  147. <CardContent>
  148. <div className="text-center mb-6">
  149. <div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-bambu-green/20 mb-3">
  150. <Cloud className="w-6 h-6 text-bambu-green" />
  151. </div>
  152. <h2 className="text-xl font-semibold text-white">{t('profiles.login.title')}</h2>
  153. <p className="text-sm text-bambu-gray mt-1">{t('profiles.login.subtitle')}</p>
  154. </div>
  155. <form onSubmit={handleSubmit} className="space-y-4">
  156. {step === 'email' && (
  157. <>
  158. <div>
  159. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.email')}</label>
  160. <input
  161. type="email"
  162. value={email}
  163. onChange={(e) => setEmail(e.target.value)}
  164. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
  165. placeholder="your@email.com"
  166. required
  167. />
  168. </div>
  169. <div>
  170. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.password')}</label>
  171. <input
  172. type="password"
  173. value={password}
  174. onChange={(e) => setPassword(e.target.value)}
  175. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
  176. placeholder="••••••••"
  177. required
  178. />
  179. </div>
  180. <div>
  181. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.region')}</label>
  182. <select
  183. value={region}
  184. onChange={(e) => setRegion(e.target.value)}
  185. 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"
  186. >
  187. <option value="global">{t('profiles.login.regionGlobal')}</option>
  188. <option value="china">{t('profiles.login.regionChina')}</option>
  189. </select>
  190. </div>
  191. </>
  192. )}
  193. {step === 'code' && (
  194. <div>
  195. <label className="block text-sm text-bambu-gray mb-1">
  196. {verificationType === 'totp' ? t('profiles.login.totpCode') : t('profiles.login.verificationCode')}
  197. </label>
  198. <p className="text-xs text-bambu-gray mb-2">
  199. {verificationType === 'totp'
  200. ? t('profiles.login.enterTotpHint')
  201. : t('profiles.login.checkEmail', { email })}
  202. </p>
  203. <input
  204. type="text"
  205. value={code}
  206. onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
  207. className="w-full px-3 py-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-center text-2xl tracking-widest font-mono focus:border-bambu-green focus:outline-none"
  208. placeholder="000000"
  209. maxLength={6}
  210. required
  211. />
  212. </div>
  213. )}
  214. {step === 'token' && (
  215. <>
  216. <div>
  217. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.accessToken')}</label>
  218. <p className="text-xs text-bambu-gray mb-2">{t('profiles.login.accessTokenHint')}</p>
  219. <textarea
  220. value={token}
  221. onChange={(e) => setToken(e.target.value)}
  222. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none resize-none"
  223. placeholder="eyJ..."
  224. rows={4}
  225. required
  226. />
  227. </div>
  228. <div>
  229. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.region')}</label>
  230. <select
  231. value={region}
  232. onChange={(e) => setRegion(e.target.value)}
  233. 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"
  234. >
  235. <option value="global">{t('profiles.login.regionGlobal')}</option>
  236. <option value="china">{t('profiles.login.regionChina')}</option>
  237. </select>
  238. </div>
  239. </>
  240. )}
  241. <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
  242. {step === 'code' && (
  243. <Button type="button" variant="secondary" onClick={() => setStep('email')} className="flex-1">
  244. {t('profiles.login.back')}
  245. </Button>
  246. )}
  247. <Button type="submit" disabled={isPending} className="flex-1">
  248. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogIn className="w-4 h-4" />}
  249. {step === 'email' ? t('profiles.login.loginButton') : step === 'code' ? t('profiles.login.verifyButton') : t('profiles.login.setTokenButton')}
  250. </Button>
  251. </div>
  252. {step === 'email' && (
  253. <div className="pt-4 border-t border-bambu-dark-tertiary">
  254. <button
  255. type="button"
  256. onClick={() => setStep('token')}
  257. className="text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors"
  258. >
  259. <Key className="w-4 h-4" />
  260. {t('profiles.login.useToken')}
  261. </button>
  262. </div>
  263. )}
  264. {step === 'token' && (
  265. <div className="pt-4 border-t border-bambu-dark-tertiary">
  266. <button
  267. type="button"
  268. onClick={() => setStep('email')}
  269. className="text-sm text-bambu-gray hover:text-white flex items-center gap-2 transition-colors"
  270. >
  271. <LogIn className="w-4 h-4" />
  272. {t('profiles.login.useEmail')}
  273. </button>
  274. </div>
  275. )}
  276. </form>
  277. </CardContent>
  278. </Card>
  279. );
  280. }
  281. // ============================================================================
  282. // FILTER DROPDOWN
  283. // ============================================================================
  284. function FilterDropdown({
  285. label,
  286. value,
  287. options,
  288. onChange,
  289. }: {
  290. label: string;
  291. value: string;
  292. options: { value: string; label: string; count?: number }[];
  293. onChange: (value: string) => void;
  294. }) {
  295. const [isOpen, setIsOpen] = useState(false);
  296. const selectedOption = options.find(o => o.value === value);
  297. return (
  298. <div className="relative">
  299. <button
  300. onClick={() => setIsOpen(!isOpen)}
  301. className="flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white hover:border-bambu-gray-dark transition-colors"
  302. >
  303. <span className="text-bambu-gray">{label}:</span>
  304. <span>{selectedOption?.label || 'All'}</span>
  305. <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isOpen ? 'rotate-180' : ''}`} />
  306. </button>
  307. {isOpen && (
  308. <>
  309. <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
  310. <div className="absolute top-full left-0 mt-1 min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20 py-1 max-h-60 overflow-y-auto">
  311. {options.map((option) => (
  312. <button
  313. key={option.value}
  314. onClick={() => { onChange(option.value); setIsOpen(false); }}
  315. className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover:bg-bambu-dark-tertiary transition-colors ${
  316. value === option.value ? 'text-bambu-green' : 'text-white'
  317. }`}
  318. >
  319. <span>{option.label}</span>
  320. {option.count !== undefined && (
  321. <span className="text-bambu-gray text-xs">{option.count}</span>
  322. )}
  323. </button>
  324. ))}
  325. </div>
  326. </>
  327. )}
  328. </div>
  329. );
  330. }
  331. // ============================================================================
  332. // SCROLL TO TOP BUTTON
  333. // ============================================================================
  334. function ScrollToTop() {
  335. const [isVisible, setIsVisible] = useState(false);
  336. useEffect(() => {
  337. const toggleVisibility = () => {
  338. setIsVisible(window.scrollY > 300);
  339. };
  340. window.addEventListener('scroll', toggleVisibility);
  341. return () => window.removeEventListener('scroll', toggleVisibility);
  342. }, []);
  343. const scrollToTop = () => {
  344. window.scrollTo({ top: 0, behavior: 'smooth' });
  345. };
  346. if (!isVisible) return null;
  347. return (
  348. <button
  349. onClick={scrollToTop}
  350. className="fixed bottom-6 right-6 p-3 bg-bambu-green hover:bg-bambu-green-light text-white rounded-full shadow-lg shadow-bambu-green/25 transition-all z-40"
  351. aria-label="Scroll to top"
  352. >
  353. <ArrowUp className="w-5 h-5" />
  354. </button>
  355. );
  356. }
  357. // ============================================================================
  358. // PRESET LIST ITEM (compact row style like K-Profiles)
  359. // ============================================================================
  360. function PresetListItem({
  361. setting,
  362. onClick,
  363. onDuplicate,
  364. compareMode,
  365. isCompareSelected,
  366. compareIndex,
  367. compareDisabled,
  368. t,
  369. }: {
  370. setting: SlicerSetting;
  371. onClick: () => void;
  372. onDuplicate: () => void;
  373. compareMode?: boolean;
  374. isCompareSelected?: boolean;
  375. compareIndex?: number;
  376. compareDisabled?: boolean;
  377. t: TFunction;
  378. }) {
  379. const metadata = extractMetadata(setting.name);
  380. const isEditable = isUserPreset(setting.setting_id);
  381. return (
  382. <div className="flex items-center gap-2 group">
  383. <button
  384. onClick={onClick}
  385. disabled={compareDisabled}
  386. className={`flex-1 text-left px-3 py-2 rounded transition-colors ${
  387. isCompareSelected
  388. ? 'bg-blue-500/20 border border-blue-500/50'
  389. : compareDisabled
  390. ? 'bg-bambu-dark/50 opacity-40 cursor-not-allowed'
  391. : 'bg-bambu-dark hover:bg-bambu-dark-tertiary'
  392. } ${compareMode && !compareDisabled ? 'cursor-pointer' : ''}`}
  393. >
  394. <div className="flex items-center gap-2">
  395. {isCompareSelected && compareIndex !== undefined && (
  396. <span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
  397. {compareIndex + 1}
  398. </span>
  399. )}
  400. {!isCompareSelected && isEditable && (
  401. <span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-bambu-green" title={t('profiles.presets.myPreset')} />
  402. )}
  403. <span className="text-white text-sm truncate flex-1" title={setting.name}>
  404. {setting.name}
  405. </span>
  406. {/* Show relevant metadata tag */}
  407. {metadata.filamentType && setting.type === 'filament' && (
  408. <span className="text-xs text-bambu-gray whitespace-nowrap">
  409. {metadata.filamentType}
  410. </span>
  411. )}
  412. {metadata.layerHeight && setting.type === 'process' && (
  413. <span className="text-xs text-bambu-gray whitespace-nowrap">
  414. {metadata.layerHeight}
  415. </span>
  416. )}
  417. {metadata.printer && (
  418. <span className="text-xs text-bambu-gray whitespace-nowrap">
  419. {metadata.printer}
  420. </span>
  421. )}
  422. </div>
  423. </button>
  424. <button
  425. onClick={(e) => { e.stopPropagation(); onDuplicate(); }}
  426. className="opacity-0 group-hover:opacity-100 text-bambu-gray hover:text-white transition-all p-1"
  427. title={t('profiles.presets.duplicate')}
  428. >
  429. <Copy className="w-4 h-4" />
  430. </button>
  431. </div>
  432. );
  433. }
  434. // ============================================================================
  435. // PRESET DETAIL MODAL
  436. // ============================================================================
  437. // Format JSON for display, converting escaped newlines to real newlines in string values
  438. function formatJsonForDisplay(obj: unknown, indent = 0): string {
  439. const spaces = ' '.repeat(indent);
  440. if (obj === null) return 'null';
  441. if (obj === undefined) return 'undefined';
  442. if (typeof obj === 'string') {
  443. // Convert escaped newlines to actual newlines for readability
  444. if (obj.includes('\\n') || obj.includes('\n')) {
  445. const formatted = obj
  446. .replace(/\\n/g, '\n')
  447. .replace(/\\"/g, '"')
  448. .replace(/\\t/g, '\t');
  449. // For multi-line strings, show them nicely indented
  450. const lines = formatted.split('\n');
  451. if (lines.length > 1) {
  452. return '"""\n' + lines.map(l => spaces + ' ' + l).join('\n') + '\n' + spaces + '"""';
  453. }
  454. }
  455. return JSON.stringify(obj);
  456. }
  457. if (typeof obj === 'number' || typeof obj === 'boolean') {
  458. return String(obj);
  459. }
  460. if (Array.isArray(obj)) {
  461. if (obj.length === 0) return '[]';
  462. const items = obj.map(item => spaces + ' ' + formatJsonForDisplay(item, indent + 1));
  463. return '[\n' + items.join(',\n') + '\n' + spaces + ']';
  464. }
  465. if (typeof obj === 'object') {
  466. const entries = Object.entries(obj);
  467. if (entries.length === 0) return '{}';
  468. const items = entries.map(([key, val]) =>
  469. spaces + ' ' + JSON.stringify(key) + ': ' + formatJsonForDisplay(val, indent + 1)
  470. );
  471. return '{\n' + items.join(',\n') + '\n' + spaces + '}';
  472. }
  473. return String(obj);
  474. }
  475. function PresetDetailModal({
  476. setting,
  477. onClose,
  478. onDeleted,
  479. onDuplicate,
  480. onEdit,
  481. hasPermission,
  482. t,
  483. }: {
  484. setting: SlicerSetting;
  485. onClose: () => void;
  486. onDeleted: () => void;
  487. onDuplicate: () => void;
  488. onEdit: () => void;
  489. hasPermission: (permission: Permission) => boolean;
  490. t: TFunction;
  491. }) {
  492. const { showToast } = useToast();
  493. const queryClient = useQueryClient();
  494. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  495. const { data: detail, isLoading } = useQuery<SlicerSettingDetail>({
  496. queryKey: ['cloudSettingDetail', setting.setting_id],
  497. queryFn: () => api.getCloudSettingDetail(setting.setting_id),
  498. });
  499. const deleteMutation = useMutation({
  500. mutationFn: () => api.deleteCloudSetting(setting.setting_id),
  501. onSuccess: () => {
  502. showToast(t('profiles.presets.toast.deleted'));
  503. queryClient.invalidateQueries({ queryKey: ['cloudSettings'] });
  504. onDeleted();
  505. },
  506. onError: (error: Error) => showToast(error.message, 'error'),
  507. });
  508. const isEditable = isUserPreset(setting.setting_id);
  509. const metadata = extractMetadata(setting.name, detail?.setting?.inherits as string);
  510. return (
  511. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  512. <Card className="w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden">
  513. <CardContent className="p-0 flex flex-col min-h-0 flex-1">
  514. {/* Header */}
  515. <div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  516. <div className="flex-1 min-w-0">
  517. <div className="flex items-center gap-2">
  518. <h2 className="text-xl font-semibold text-white truncate">{setting.name}</h2>
  519. {isEditable && (
  520. <span className="px-2 py-0.5 text-xs font-medium bg-bambu-green/20 text-bambu-green rounded-full">
  521. {t('profiles.presets.editable')}
  522. </span>
  523. )}
  524. </div>
  525. <div className="flex items-center gap-2 mt-1 text-sm text-bambu-gray">
  526. <span className="capitalize">{t(`profiles.presets.types.${setting.type}`)}</span>
  527. {metadata.printer && <><span>•</span><span>{metadata.printer}</span></>}
  528. </div>
  529. </div>
  530. <button onClick={onClose} className="p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
  531. <X className="w-5 h-5" />
  532. </button>
  533. </div>
  534. {/* Content */}
  535. <div className="flex-1 min-h-0 overflow-y-auto p-4">
  536. {isLoading ? (
  537. <div className="flex items-center justify-center py-16">
  538. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  539. </div>
  540. ) : detail ? (
  541. <pre className="text-xs text-bambu-gray font-mono whitespace-pre-wrap break-all bg-bambu-dark p-4 rounded-lg border border-bambu-dark-tertiary overflow-x-auto max-w-full">
  542. {formatJsonForDisplay(detail)}
  543. </pre>
  544. ) : (
  545. <div className="text-center py-16 text-bambu-gray">{t('profiles.presets.failedToLoadDetails')}</div>
  546. )}
  547. </div>
  548. {/* Footer */}
  549. {showDeleteConfirm ? (
  550. <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary bg-red-500/5">
  551. <div className="flex items-center gap-2 mb-3 text-red-400">
  552. <AlertTriangle className="w-5 h-5" />
  553. <span className="font-medium">{t('profiles.presets.deleteConfirm')}</span>
  554. </div>
  555. <p className="text-sm text-bambu-gray mb-4">
  556. {t('profiles.presets.deleteWarning', { name: setting.name })}
  557. </p>
  558. <div className="flex gap-2">
  559. <Button variant="secondary" onClick={() => setShowDeleteConfirm(false)} disabled={deleteMutation.isPending} className="flex-1">
  560. {t('common.cancel')}
  561. </Button>
  562. <Button variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} className="flex-1">
  563. {deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
  564. {t('common.delete')}
  565. </Button>
  566. </div>
  567. </div>
  568. ) : (
  569. <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
  570. <div className="flex gap-2">
  571. <Button variant="secondary" onClick={onClose} className="flex-1">{t('common.close')}</Button>
  572. <Button
  573. variant="secondary"
  574. onClick={onDuplicate}
  575. disabled={!hasPermission('cloud:auth')}
  576. title={!hasPermission('cloud:auth') ? t('profiles.presets.noDuplicatePermission') : undefined}
  577. >
  578. <Copy className="w-4 h-4" />
  579. {t('profiles.presets.duplicate')}
  580. </Button>
  581. {isEditable && (
  582. <>
  583. <Button
  584. variant="secondary"
  585. onClick={onEdit}
  586. disabled={isLoading || !detail || !hasPermission('cloud:auth')}
  587. title={!hasPermission('cloud:auth') ? t('profiles.presets.noEditPermission') : undefined}
  588. >
  589. <Pencil className="w-4 h-4" />
  590. {t('common.edit')}
  591. </Button>
  592. <Button
  593. variant="danger"
  594. onClick={() => setShowDeleteConfirm(true)}
  595. disabled={!hasPermission('cloud:auth')}
  596. title={!hasPermission('cloud:auth') ? t('profiles.presets.noDeletePermission') : undefined}
  597. >
  598. <Trash2 className="w-4 h-4" />
  599. </Button>
  600. </>
  601. )}
  602. </div>
  603. </div>
  604. )}
  605. </CardContent>
  606. </Card>
  607. </div>
  608. );
  609. }
  610. // ============================================================================
  611. // TEMPLATES
  612. // ============================================================================
  613. type EditorTab = 'common' | 'fields' | 'json';
  614. interface CustomTemplate {
  615. id: string;
  616. name: string;
  617. description: string;
  618. type: 'filament' | 'print' | 'printer';
  619. settings: Record<string, unknown>;
  620. showInModal?: boolean; // If true, show in add/edit preset modals
  621. }
  622. // Load custom templates from localStorage
  623. function loadCustomTemplates(): CustomTemplate[] {
  624. try {
  625. const stored = localStorage.getItem('bambusy_preset_templates');
  626. return stored ? JSON.parse(stored) : [];
  627. } catch {
  628. return [];
  629. }
  630. }
  631. // Save custom templates to localStorage
  632. function saveCustomTemplates(templates: CustomTemplate[]) {
  633. localStorage.setItem('bambusy_preset_templates', JSON.stringify(templates));
  634. }
  635. // ============================================================================
  636. // TEMPLATES MODAL (manage templates from main page)
  637. // ============================================================================
  638. function TemplatesModal({
  639. onClose,
  640. onApply,
  641. t,
  642. }: {
  643. onClose: () => void;
  644. onApply: (template: CustomTemplate) => void;
  645. t: TFunction;
  646. }) {
  647. const { showToast } = useToast();
  648. const [templates, setTemplates] = useState<CustomTemplate[]>(loadCustomTemplates);
  649. const [filterType, setFilterType] = useState<'all' | 'filament' | 'print' | 'printer'>('all');
  650. const [editingId, setEditingId] = useState<string | null>(null);
  651. const [editName, setEditName] = useState('');
  652. const [editDesc, setEditDesc] = useState('');
  653. const [editSettings, setEditSettings] = useState('{}');
  654. const [editSettingsError, setEditSettingsError] = useState<string | null>(null);
  655. const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
  656. const filteredTemplates = filterType === 'all'
  657. ? templates
  658. : templates.filter(tpl => tpl.type === filterType);
  659. const saveTemplates = (updated: CustomTemplate[]) => {
  660. setTemplates(updated);
  661. saveCustomTemplates(updated);
  662. };
  663. const handleDelete = (id: string) => {
  664. const updated = templates.filter(tpl => tpl.id !== id);
  665. saveTemplates(updated);
  666. setDeleteConfirmId(null);
  667. showToast(t('profiles.templates.toast.deleted'));
  668. };
  669. const handleEdit = (template: CustomTemplate) => {
  670. setEditingId(template.id);
  671. setEditName(template.name);
  672. setEditDesc(template.description);
  673. setEditSettings(JSON.stringify(template.settings, null, 2));
  674. setEditSettingsError(null);
  675. };
  676. const handleSaveEdit = () => {
  677. if (!editingId || !editName.trim()) return;
  678. try {
  679. const settings = JSON.parse(editSettings);
  680. const updated = templates.map(tpl =>
  681. tpl.id === editingId
  682. ? { ...tpl, name: editName.trim(), description: editDesc.trim(), settings }
  683. : tpl
  684. );
  685. saveTemplates(updated);
  686. setEditingId(null);
  687. showToast(t('profiles.templates.toast.updated'));
  688. } catch (e) {
  689. setEditSettingsError((e as Error).message);
  690. }
  691. };
  692. const handleCancelEdit = () => {
  693. setEditingId(null);
  694. setEditName('');
  695. setEditDesc('');
  696. setEditSettings('{}');
  697. setEditSettingsError(null);
  698. };
  699. const toggleShowInModal = (id: string) => {
  700. const updated = templates.map(tpl =>
  701. tpl.id === id ? { ...tpl, showInModal: !tpl.showInModal } : tpl
  702. );
  703. saveTemplates(updated);
  704. };
  705. const typeLabels = {
  706. filament: { label: t('profiles.presets.types.filament'), icon: Droplet, color: 'text-amber-400' },
  707. print: { label: t('profiles.presets.types.process'), icon: Settings2, color: 'text-blue-400' },
  708. printer: { label: t('profiles.presets.types.printer'), icon: PrinterIcon, color: 'text-purple-400' },
  709. };
  710. const templateToDelete = deleteConfirmId ? templates.find(tpl => tpl.id === deleteConfirmId) : null;
  711. // Handle Escape key
  712. useEffect(() => {
  713. const handleKeyDown = (e: KeyboardEvent) => {
  714. if (e.key === 'Escape') {
  715. if (deleteConfirmId) {
  716. setDeleteConfirmId(null);
  717. } else if (editingId) {
  718. handleCancelEdit();
  719. } else {
  720. onClose();
  721. }
  722. }
  723. };
  724. window.addEventListener('keydown', handleKeyDown);
  725. return () => window.removeEventListener('keydown', handleKeyDown);
  726. }, [deleteConfirmId, editingId, onClose]);
  727. return (
  728. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  729. {/* Delete confirmation modal */}
  730. {templateToDelete && (
  731. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60">
  732. <Card className="w-full max-w-md">
  733. <CardContent className="p-6">
  734. <div className="flex items-center gap-3 mb-4">
  735. <div className="p-2 bg-red-500/20 rounded-lg">
  736. <AlertTriangle className="w-6 h-6 text-red-400" />
  737. </div>
  738. <div>
  739. <h3 className="text-lg font-semibold text-white">{t('profiles.templates.deleteTitle')}</h3>
  740. <p className="text-sm text-bambu-gray">{t('profiles.templates.deleteWarning')}</p>
  741. </div>
  742. </div>
  743. <p className="text-white mb-6">
  744. {t('profiles.templates.deleteConfirm', { name: templateToDelete.name })}
  745. </p>
  746. <div className="flex gap-2">
  747. <Button variant="secondary" onClick={() => setDeleteConfirmId(null)} className="flex-1">
  748. {t('common.cancel')}
  749. </Button>
  750. <Button onClick={() => handleDelete(deleteConfirmId!)} className="flex-1 bg-red-500 hover:bg-red-600">
  751. <Trash2 className="w-4 h-4" />
  752. {t('common.delete')}
  753. </Button>
  754. </div>
  755. </CardContent>
  756. </Card>
  757. </div>
  758. )}
  759. <Card className="w-full max-w-2xl max-h-[80vh] flex flex-col">
  760. <CardContent className="p-0 flex flex-col h-full">
  761. {/* Header */}
  762. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  763. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  764. <Sparkles className="w-5 h-5 text-amber-400" />
  765. {t('profiles.templates.title')}
  766. </h2>
  767. <button onClick={onClose} className="text-bambu-gray hover:text-white">
  768. <X className="w-5 h-5" />
  769. </button>
  770. </div>
  771. {/* Filter row */}
  772. <div className="flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary">
  773. <span className="text-sm text-bambu-gray">{t('profiles.templates.typeFilter')}</span>
  774. {(['all', 'filament', 'print', 'printer'] as const).map((type) => (
  775. <button
  776. key={type}
  777. onClick={() => setFilterType(type)}
  778. className={`px-3 py-1 text-sm rounded-lg transition-colors ${
  779. filterType === type
  780. ? 'bg-bambu-green text-white'
  781. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  782. }`}
  783. >
  784. {type === 'all' ? t('common.all') : typeLabels[type].label}
  785. </button>
  786. ))}
  787. </div>
  788. {/* Templates list */}
  789. <div className="flex-1 overflow-y-auto p-4">
  790. {filteredTemplates.length === 0 ? (
  791. <div className="text-center py-12 text-bambu-gray">
  792. <Sparkles className="w-12 h-12 mx-auto mb-4 opacity-30" />
  793. <p>{t('profiles.templates.noTemplates')}</p>
  794. <p className="text-sm mt-1">{t('profiles.templates.createFirst')}</p>
  795. </div>
  796. ) : (
  797. <div className="space-y-2">
  798. {filteredTemplates.map((template) => {
  799. const typeInfo = typeLabels[template.type];
  800. const TypeIcon = typeInfo.icon;
  801. if (editingId === template.id) {
  802. return (
  803. <div
  804. key={template.id}
  805. className="p-4 bg-bambu-dark rounded-lg border border-bambu-green"
  806. >
  807. <div className="grid grid-cols-2 gap-3 mb-3">
  808. <input
  809. type="text"
  810. value={editName}
  811. onChange={(e) => setEditName(e.target.value)}
  812. placeholder={t('profiles.templates.namePlaceholder')}
  813. className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  814. autoFocus
  815. />
  816. <input
  817. type="text"
  818. value={editDesc}
  819. onChange={(e) => setEditDesc(e.target.value)}
  820. placeholder={t('profiles.templates.descriptionPlaceholder')}
  821. className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  822. />
  823. </div>
  824. <div className="mb-3">
  825. <label className="text-xs text-bambu-gray mb-1 block">{t('profiles.templates.settingsJson')}</label>
  826. <textarea
  827. value={editSettings}
  828. onChange={(e) => {
  829. setEditSettings(e.target.value);
  830. setEditSettingsError(null);
  831. }}
  832. rows={6}
  833. className={`w-full px-3 py-2 bg-bambu-dark-secondary border rounded text-white text-sm font-mono focus:outline-none ${
  834. editSettingsError ? 'border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
  835. }`}
  836. />
  837. {editSettingsError && (
  838. <p className="text-xs text-red-400 mt-1">{editSettingsError}</p>
  839. )}
  840. </div>
  841. <div className="flex gap-2">
  842. <Button size="sm" onClick={handleSaveEdit} disabled={!editName.trim()}>
  843. <Save className="w-4 h-4" />
  844. Save
  845. </Button>
  846. <Button size="sm" variant="secondary" onClick={handleCancelEdit}>
  847. Cancel
  848. </Button>
  849. </div>
  850. </div>
  851. );
  852. }
  853. return (
  854. <div
  855. key={template.id}
  856. className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary hover:border-bambu-gray-dark transition-colors"
  857. >
  858. <TypeIcon className={`w-5 h-5 ${typeInfo.color} flex-shrink-0`} />
  859. <div className="flex-1 min-w-0">
  860. <p className="text-sm font-medium text-white">{template.name}</p>
  861. <p className="text-xs text-bambu-gray truncate">{template.description}</p>
  862. </div>
  863. <span className="text-xs text-bambu-gray-dark px-2 py-1 bg-bambu-dark-secondary rounded">
  864. {t('profiles.templates.fieldsCount', { count: Object.keys(template.settings).length })}
  865. </span>
  866. <button
  867. onClick={() => toggleShowInModal(template.id)}
  868. className={`p-1 transition-colors ${
  869. template.showInModal
  870. ? 'text-bambu-green hover:text-bambu-green/70'
  871. : 'text-bambu-gray hover:text-white'
  872. }`}
  873. title={template.showInModal ? t('profiles.templates.shownInModals') : t('profiles.templates.hiddenInModals')}
  874. >
  875. {template.showInModal ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
  876. </button>
  877. <button
  878. onClick={() => onApply(template)}
  879. className="px-3 py-1 text-xs bg-bambu-green/20 text-bambu-green rounded hover:bg-bambu-green/30 transition-colors"
  880. >
  881. {t('profiles.templates.apply')}
  882. </button>
  883. <button
  884. onClick={() => handleEdit(template)}
  885. className="p-1 text-bambu-gray hover:text-white"
  886. title={t('common.edit')}
  887. >
  888. <Pencil className="w-4 h-4" />
  889. </button>
  890. <button
  891. onClick={() => setDeleteConfirmId(template.id)}
  892. className="p-1 text-bambu-gray hover:text-red-400"
  893. title={t('common.delete')}
  894. >
  895. <Trash2 className="w-4 h-4" />
  896. </button>
  897. </div>
  898. );
  899. })}
  900. </div>
  901. )}
  902. </div>
  903. </CardContent>
  904. </Card>
  905. </div>
  906. );
  907. }
  908. // ============================================================================
  909. // DIFF MODAL - Compare two presets or preset vs base
  910. // ============================================================================
  911. type DiffEntry = {
  912. key: string;
  913. left: unknown;
  914. right: unknown;
  915. status: 'added' | 'removed' | 'changed' | 'same';
  916. };
  917. function DiffModal({
  918. onClose,
  919. leftPreset,
  920. rightPreset,
  921. leftLabel,
  922. rightLabel,
  923. t,
  924. }: {
  925. onClose: () => void;
  926. leftPreset: Record<string, unknown>;
  927. rightPreset: Record<string, unknown>;
  928. leftLabel: string;
  929. rightLabel: string;
  930. t: TFunction;
  931. }) {
  932. const [filterMode, setFilterMode] = useState<'changes' | 'all'>('changes');
  933. const [searchQuery, setSearchQuery] = useState('');
  934. // Calculate diff
  935. const diffEntries = useMemo(() => {
  936. const allKeys = new Set([...Object.keys(leftPreset), ...Object.keys(rightPreset)]);
  937. const entries: DiffEntry[] = [];
  938. for (const key of allKeys) {
  939. // Skip internal fields
  940. if (key === 'inherits' || key === 'version') continue;
  941. const leftVal = leftPreset[key];
  942. const rightVal = rightPreset[key];
  943. const leftExists = key in leftPreset;
  944. const rightExists = key in rightPreset;
  945. let status: DiffEntry['status'];
  946. if (!leftExists && rightExists) {
  947. status = 'added';
  948. } else if (leftExists && !rightExists) {
  949. status = 'removed';
  950. } else if (JSON.stringify(leftVal) !== JSON.stringify(rightVal)) {
  951. status = 'changed';
  952. } else {
  953. status = 'same';
  954. }
  955. entries.push({ key, left: leftVal, right: rightVal, status });
  956. }
  957. return entries.sort((a, b) => {
  958. // Sort by status (changed first, then added, removed, same)
  959. const statusOrder = { changed: 0, added: 1, removed: 2, same: 3 };
  960. if (statusOrder[a.status] !== statusOrder[b.status]) {
  961. return statusOrder[a.status] - statusOrder[b.status];
  962. }
  963. return a.key.localeCompare(b.key);
  964. });
  965. }, [leftPreset, rightPreset]);
  966. // Filter entries
  967. const filteredEntries = useMemo(() => {
  968. let entries = [...diffEntries];
  969. if (filterMode === 'changes') {
  970. entries = entries.filter(e => e.status !== 'same');
  971. }
  972. if (searchQuery) {
  973. const q = searchQuery.toLowerCase();
  974. entries = entries.filter(e =>
  975. e.key.toLowerCase().includes(q) ||
  976. String(e.left).toLowerCase().includes(q) ||
  977. String(e.right).toLowerCase().includes(q)
  978. );
  979. }
  980. return entries;
  981. }, [diffEntries, filterMode, searchQuery]);
  982. // Stats
  983. const stats = useMemo(() => {
  984. return {
  985. added: diffEntries.filter(e => e.status === 'added').length,
  986. removed: diffEntries.filter(e => e.status === 'removed').length,
  987. changed: diffEntries.filter(e => e.status === 'changed').length,
  988. same: diffEntries.filter(e => e.status === 'same').length,
  989. };
  990. }, [diffEntries]);
  991. const formatValue = (val: unknown): string => {
  992. if (val === undefined) return '—';
  993. if (val === null) return 'null';
  994. if (Array.isArray(val)) {
  995. // Show arrays more cleanly
  996. if (val.length === 0) return '[]';
  997. if (val.length === 1) return String(val[0]);
  998. return val.join(', ');
  999. }
  1000. if (typeof val === 'object') return JSON.stringify(val);
  1001. // Handle strings - truncate long ones and clean up escaped chars
  1002. const str = String(val);
  1003. // Check if it looks like G-code or multi-line content
  1004. if (str.includes('\\n') || str.length > 100) {
  1005. // Count lines and show summary
  1006. const lines = str.split('\\n').length;
  1007. if (lines > 1) {
  1008. return `[${lines} lines of G-code/script]`;
  1009. }
  1010. if (str.length > 100) {
  1011. return str.substring(0, 100) + '…';
  1012. }
  1013. }
  1014. return str;
  1015. };
  1016. // Handle Escape key
  1017. useEffect(() => {
  1018. const handleKeyDown = (e: KeyboardEvent) => {
  1019. if (e.key === 'Escape') onClose();
  1020. };
  1021. window.addEventListener('keydown', handleKeyDown);
  1022. return () => window.removeEventListener('keydown', handleKeyDown);
  1023. }, [onClose]);
  1024. return (
  1025. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
  1026. <Card className="w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
  1027. <CardContent className="p-0 flex flex-col min-h-0 flex-1">
  1028. {/* Header */}
  1029. <div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  1030. <h2 className="text-lg font-semibold text-white flex items-center gap-2">
  1031. <GitCompare className="w-5 h-5 text-blue-400" />
  1032. {t('profiles.diff.title')}
  1033. </h2>
  1034. <button onClick={onClose} className="text-bambu-gray hover:text-white">
  1035. <X className="w-5 h-5" />
  1036. </button>
  1037. </div>
  1038. {/* Preset labels */}
  1039. <div className="flex-shrink-0 grid grid-cols-2 gap-4 p-4 border-b border-bambu-dark-tertiary bg-bambu-dark">
  1040. <div className="text-center">
  1041. <span className="text-sm text-bambu-gray">{t('profiles.diff.left')}</span>
  1042. <p className="text-white font-medium truncate">{leftLabel}</p>
  1043. </div>
  1044. <div className="text-center">
  1045. <span className="text-sm text-bambu-gray">{t('profiles.diff.right')}</span>
  1046. <p className="text-white font-medium truncate">{rightLabel}</p>
  1047. </div>
  1048. </div>
  1049. {/* Stats and filters */}
  1050. <div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  1051. <div className="flex items-center gap-4 text-sm">
  1052. <span className="flex items-center gap-1 text-green-400">
  1053. <PlusIcon className="w-3.5 h-3.5" />
  1054. {stats.added} {t('profiles.diff.added')}
  1055. </span>
  1056. <span className="flex items-center gap-1 text-red-400">
  1057. <MinusIcon className="w-3.5 h-3.5" />
  1058. {stats.removed} {t('profiles.diff.removed')}
  1059. </span>
  1060. <span className="flex items-center gap-1 text-amber-400">
  1061. <ArrowRight className="w-3.5 h-3.5" />
  1062. {stats.changed} {t('profiles.diff.changed')}
  1063. </span>
  1064. <span className="flex items-center gap-1 text-bambu-gray">
  1065. <Equal className="w-3.5 h-3.5" />
  1066. {stats.same} {t('profiles.diff.same')}
  1067. </span>
  1068. </div>
  1069. <div className="flex items-center gap-3">
  1070. <div className="relative">
  1071. <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  1072. <input
  1073. type="text"
  1074. value={searchQuery}
  1075. onChange={(e) => setSearchQuery(e.target.value)}
  1076. placeholder={t('profiles.diff.searchFields')}
  1077. className="pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none w-48"
  1078. />
  1079. </div>
  1080. {stats.same > 0 && (
  1081. <div className="flex rounded overflow-hidden border border-bambu-dark-tertiary">
  1082. <button
  1083. onClick={() => setFilterMode('changes')}
  1084. className={`px-3 py-1.5 text-sm transition-colors ${
  1085. filterMode === 'changes'
  1086. ? 'bg-bambu-green text-white'
  1087. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  1088. }`}
  1089. >
  1090. {t('profiles.diff.changes')}
  1091. </button>
  1092. <button
  1093. onClick={() => setFilterMode('all')}
  1094. className={`px-3 py-1.5 text-sm transition-colors ${
  1095. filterMode === 'all'
  1096. ? 'bg-bambu-green text-white'
  1097. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  1098. }`}
  1099. >
  1100. {t('common.all')}
  1101. </button>
  1102. </div>
  1103. )}
  1104. </div>
  1105. </div>
  1106. {/* Diff table */}
  1107. <div className="flex-1 min-h-0 overflow-y-auto">
  1108. {filteredEntries.length === 0 ? (
  1109. <div className="text-center py-12 text-bambu-gray">
  1110. <Equal className="w-12 h-12 mx-auto mb-4 opacity-30" />
  1111. <p>{filterMode === 'changes' ? t('profiles.diff.noDifferences') : t('profiles.diff.noFieldsMatch')}</p>
  1112. </div>
  1113. ) : (
  1114. <table className="w-full">
  1115. <thead className="sticky top-0 bg-bambu-dark-secondary">
  1116. <tr className="text-sm text-bambu-gray border-b border-bambu-dark-tertiary">
  1117. <th className="text-left p-3 w-1/3">{t('profiles.diff.field')}</th>
  1118. <th className="text-left p-3 w-1/3">{leftLabel}</th>
  1119. <th className="text-left p-3 w-1/3">{rightLabel}</th>
  1120. </tr>
  1121. </thead>
  1122. <tbody>
  1123. {filteredEntries.map((entry) => {
  1124. const bgClass = {
  1125. added: 'bg-green-500/10',
  1126. removed: 'bg-red-500/10',
  1127. changed: 'bg-amber-500/10',
  1128. same: '',
  1129. }[entry.status];
  1130. const statusIcon = {
  1131. added: <PlusIcon className="w-3.5 h-3.5 text-green-400" />,
  1132. removed: <MinusIcon className="w-3.5 h-3.5 text-red-400" />,
  1133. changed: <ArrowRight className="w-3.5 h-3.5 text-amber-400" />,
  1134. same: <Equal className="w-3.5 h-3.5 text-bambu-gray-dark" />,
  1135. }[entry.status];
  1136. return (
  1137. <tr key={entry.key} className={`border-b border-bambu-dark-tertiary ${bgClass}`}>
  1138. <td className="p-3">
  1139. <div className="flex items-center gap-2">
  1140. {statusIcon}
  1141. <span className="text-sm text-white font-mono">{entry.key}</span>
  1142. </div>
  1143. </td>
  1144. <td className="p-3">
  1145. <span className={`text-sm font-mono break-all ${
  1146. entry.status === 'removed' ? 'text-red-300' :
  1147. entry.status === 'changed' ? 'text-white' : 'text-bambu-gray'
  1148. }`}>
  1149. {formatValue(entry.left)}
  1150. </span>
  1151. </td>
  1152. <td className="p-3">
  1153. <span className={`text-sm font-mono break-all ${
  1154. entry.status === 'added' ? 'text-green-300' :
  1155. entry.status === 'changed' ? 'text-white' : 'text-bambu-gray'
  1156. }`}>
  1157. {formatValue(entry.right)}
  1158. </span>
  1159. </td>
  1160. </tr>
  1161. );
  1162. })}
  1163. </tbody>
  1164. </table>
  1165. )}
  1166. </div>
  1167. </CardContent>
  1168. </Card>
  1169. </div>
  1170. );
  1171. }
  1172. // ============================================================================
  1173. // CREATE PRESET MODAL
  1174. // ============================================================================
  1175. function CreatePresetModal({
  1176. onClose,
  1177. initialData,
  1178. allPresets,
  1179. t,
  1180. }: {
  1181. onClose: () => void;
  1182. initialData?: { type: string; name: string; base_id: string; setting: Record<string, unknown>; setting_id?: string };
  1183. allPresets: SlicerSettingsResponse;
  1184. t: TFunction;
  1185. }) {
  1186. const { showToast } = useToast();
  1187. const queryClient = useQueryClient();
  1188. // Editing mode if initialData has setting_id
  1189. const isEditMode = !!initialData?.setting_id;
  1190. const [activeTab, setActiveTab] = useState<EditorTab>('common');
  1191. const [presetType, setPresetType] = useState<'filament' | 'print' | 'printer'>(
  1192. (initialData?.type as 'filament' | 'print' | 'printer') || 'filament'
  1193. );
  1194. const [name, setName] = useState(
  1195. initialData?.name
  1196. ? (isEditMode ? initialData.name : `${initialData.name} (Copy)`)
  1197. : ''
  1198. );
  1199. const [baseId, setBaseId] = useState(initialData?.base_id || '');
  1200. const [baseName, setBaseName] = useState('');
  1201. const [settingsObj, setSettingsObj] = useState<Record<string, unknown>>(
  1202. initialData?.setting || { inherits: '' }
  1203. );
  1204. const [jsonText, setJsonText] = useState(JSON.stringify(initialData?.setting || { inherits: '' }, null, 2));
  1205. const [jsonError, setJsonError] = useState<string | null>(null);
  1206. const [fieldSearch, setFieldSearch] = useState('');
  1207. const [isDragging, setIsDragging] = useState(false);
  1208. const [customFieldKey, setCustomFieldKey] = useState('');
  1209. const [showCustomFieldInput, setShowCustomFieldInput] = useState(false);
  1210. const [customTemplates, setCustomTemplates] = useState<CustomTemplate[]>(loadCustomTemplates);
  1211. const [showSaveTemplate, setShowSaveTemplate] = useState(false);
  1212. const [newTemplateName, setNewTemplateName] = useState('');
  1213. const [newTemplateDesc, setNewTemplateDesc] = useState('');
  1214. const [newTemplateShowInModal, setNewTemplateShowInModal] = useState(true);
  1215. const [appliedTemplateName, setAppliedTemplateName] = useState<string | null>(null);
  1216. const [showDiffModal, setShowDiffModal] = useState(false);
  1217. // Fetch ALL preset details for the current type to discover all available fields
  1218. const presetsOfType = useMemo(() => {
  1219. const typeMap: Record<string, SlicerSetting[]> = {
  1220. filament: allPresets.filament,
  1221. print: allPresets.process,
  1222. printer: allPresets.printer,
  1223. };
  1224. return typeMap[presetType] || [];
  1225. }, [allPresets, presetType]);
  1226. // Only fetch details for USER presets (not Bambu's built-in ones which return 500)
  1227. const userPresetsOfType = useMemo(() => {
  1228. return presetsOfType.filter(p => isUserPreset(p.setting_id));
  1229. }, [presetsOfType]);
  1230. // Fetch field definitions from API (cached, only loaded once per type)
  1231. const { data: fieldDefinitions } = useQuery({
  1232. queryKey: ['cloudFields', presetType],
  1233. queryFn: () => api.getCloudFields(presetType === 'print' ? 'process' : presetType),
  1234. staleTime: 1000 * 60 * 60, // Cache for 1 hour
  1235. });
  1236. // Fetch details for user presets of this type (for field discovery)
  1237. const { data: allPresetDetails } = useQuery({
  1238. queryKey: ['allPresetDetails', presetType, userPresetsOfType.map(p => p.setting_id).join(',')],
  1239. queryFn: async () => {
  1240. // Fetch all preset details in parallel (limit concurrency to avoid overwhelming API)
  1241. const results: Record<string, SlicerSettingDetail> = {};
  1242. const batchSize = 5;
  1243. for (let i = 0; i < userPresetsOfType.length; i += batchSize) {
  1244. const batch = userPresetsOfType.slice(i, i + batchSize);
  1245. const batchResults = await Promise.all(
  1246. batch.map(async (preset) => {
  1247. try {
  1248. const detail = await api.getCloudSettingDetail(preset.setting_id);
  1249. return { id: preset.setting_id, detail };
  1250. } catch {
  1251. return null;
  1252. }
  1253. })
  1254. );
  1255. batchResults.forEach(r => {
  1256. if (r) results[r.id] = r.detail;
  1257. });
  1258. }
  1259. return results;
  1260. },
  1261. enabled: userPresetsOfType.length > 0,
  1262. staleTime: 1000 * 60 * 10, // Cache for 10 minutes
  1263. });
  1264. // Fetch base preset details (works for both user presets and built-in presets with new API version)
  1265. const { data: basePresetDetail, isLoading: isLoadingBasePreset } = useQuery<SlicerSettingDetail>({
  1266. queryKey: ['cloudSettingDetail', baseId],
  1267. queryFn: () => api.getCloudSettingDetail(baseId),
  1268. enabled: !!baseId,
  1269. });
  1270. // Sync JSON text with settings object
  1271. useEffect(() => {
  1272. if (activeTab !== 'json') {
  1273. setJsonText(JSON.stringify(settingsObj, null, 2));
  1274. }
  1275. }, [settingsObj, activeTab]);
  1276. // Get presets filtered by selected type - only built-in presets allowed as base
  1277. // (Bambu Cloud only allows custom presets to inherit from built-in presets)
  1278. const availableBasePresets = useMemo(() => {
  1279. const typeMap: Record<string, SlicerSetting[]> = {
  1280. filament: allPresets.filament,
  1281. print: allPresets.process,
  1282. printer: allPresets.printer,
  1283. };
  1284. return (typeMap[presetType] || [])
  1285. .filter(p => !isUserPreset(p.setting_id)) // Only built-in presets
  1286. .sort((a, b) => a.name.localeCompare(b.name));
  1287. }, [allPresets, presetType]);
  1288. // Set inherits field when base preset changes (don't pre-fill all values - they show as placeholders)
  1289. // In edit mode, don't reset settingsObj - keep the saved values
  1290. useEffect(() => {
  1291. if (!baseId) return;
  1292. const preset = availableBasePresets.find(p => p.setting_id === baseId);
  1293. if (preset) {
  1294. setBaseName(preset.name);
  1295. // Don't reset settings in edit mode - keep saved values
  1296. if (!isEditMode) {
  1297. setSettingsObj({ inherits: preset.name });
  1298. setJsonText(JSON.stringify({ inherits: preset.name }, null, 2));
  1299. }
  1300. }
  1301. }, [baseId, availableBasePresets, isEditMode]);
  1302. // Build dynamic fields list: merge API definitions with discovered fields from user presets
  1303. const dynamicFields = useMemo(() => {
  1304. // Use API field definitions if available
  1305. const knownFields: FieldDefinition[] = fieldDefinitions?.fields || [];
  1306. const knownKeySet = new Set(knownFields.map(f => f.key));
  1307. // Collect all unique field keys from ALL user presets of this type
  1308. const discoveredKeys = new Set<string>();
  1309. const excludeKeys = new Set(['inherits', 'updated_time', 'compatible_printers', 'compatible_prints']);
  1310. // From all preset details
  1311. if (allPresetDetails) {
  1312. Object.values(allPresetDetails).forEach(detail => {
  1313. if (detail?.setting) {
  1314. Object.keys(detail.setting).forEach(key => {
  1315. if (!knownKeySet.has(key) && !excludeKeys.has(key)) {
  1316. discoveredKeys.add(key);
  1317. }
  1318. });
  1319. }
  1320. });
  1321. }
  1322. // From current settings (in case user added custom fields)
  1323. Object.keys(settingsObj).forEach(key => {
  1324. if (!knownKeySet.has(key) && !excludeKeys.has(key)) {
  1325. discoveredKeys.add(key);
  1326. }
  1327. });
  1328. // Create field definitions for discovered keys (generic text inputs)
  1329. const discoveredFields: FieldDefinition[] = Array.from(discoveredKeys)
  1330. .sort()
  1331. .map(key => ({
  1332. key,
  1333. label: key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
  1334. type: 'text' as const,
  1335. category: 'discovered',
  1336. description: t('profiles.presets.discoveredFromPresets'),
  1337. }));
  1338. return [...knownFields, ...discoveredFields];
  1339. }, [fieldDefinitions, allPresetDetails, settingsObj, t]);
  1340. // Filter fields for search
  1341. const filteredFields = dynamicFields.filter(f =>
  1342. f.label.toLowerCase().includes(fieldSearch.toLowerCase()) ||
  1343. f.key.toLowerCase().includes(fieldSearch.toLowerCase())
  1344. );
  1345. // Add a custom field
  1346. const addCustomField = () => {
  1347. if (customFieldKey.trim()) {
  1348. const key = customFieldKey.trim().toLowerCase().replace(/\s+/g, '_');
  1349. updateField(key, '');
  1350. setCustomFieldKey('');
  1351. setShowCustomFieldInput(false);
  1352. showToast(t('profiles.presets.toast.fieldAdded', { key }));
  1353. }
  1354. };
  1355. // Update a single field
  1356. const updateField = (key: string, value: unknown) => {
  1357. setSettingsObj(prev => {
  1358. const newObj = { ...prev };
  1359. if (value === '' || value === undefined) {
  1360. delete newObj[key];
  1361. } else {
  1362. newObj[key] = value;
  1363. }
  1364. return newObj;
  1365. });
  1366. };
  1367. // Apply a template
  1368. const applyTemplate = (template: { name: string; settings: Record<string, unknown> }) => {
  1369. setSettingsObj(prev => ({ ...prev, ...template.settings }));
  1370. setAppliedTemplateName(template.name);
  1371. showToast(t('profiles.templates.toast.applied'));
  1372. };
  1373. // Save current settings as a template
  1374. const saveAsTemplate = () => {
  1375. if (!newTemplateName.trim()) return;
  1376. const overrides = { ...settingsObj };
  1377. delete overrides.inherits;
  1378. if (Object.keys(overrides).length === 0) {
  1379. showToast(t('profiles.presets.noOverridesToSave'), 'error');
  1380. return;
  1381. }
  1382. const newTemplate: CustomTemplate = {
  1383. id: Date.now().toString(),
  1384. name: newTemplateName.trim(),
  1385. description: newTemplateDesc.trim() || t('profiles.presets.customTemplate'),
  1386. type: presetType,
  1387. settings: overrides,
  1388. showInModal: newTemplateShowInModal,
  1389. };
  1390. const updated = [...customTemplates, newTemplate];
  1391. setCustomTemplates(updated);
  1392. saveCustomTemplates(updated);
  1393. setShowSaveTemplate(false);
  1394. setNewTemplateName('');
  1395. setNewTemplateDesc('');
  1396. setNewTemplateShowInModal(true);
  1397. showToast(t('profiles.templates.toast.created'));
  1398. };
  1399. // Get templates for current type (only those marked to show in modals)
  1400. const templatesForType = useMemo(() => {
  1401. return customTemplates.filter(t => t.type === presetType && t.showInModal);
  1402. }, [presetType, customTemplates]);
  1403. // Handle JSON edit
  1404. const handleJsonChange = (text: string) => {
  1405. setJsonText(text);
  1406. try {
  1407. const parsed = JSON.parse(text);
  1408. setSettingsObj(parsed);
  1409. setJsonError(null);
  1410. } catch (e) {
  1411. setJsonError((e as Error).message);
  1412. }
  1413. };
  1414. // Handle file drop
  1415. const handleFileDrop = (e: React.DragEvent) => {
  1416. e.preventDefault();
  1417. setIsDragging(false);
  1418. const file = e.dataTransfer.files[0];
  1419. if (file && file.name.endsWith('.json')) {
  1420. const reader = new FileReader();
  1421. reader.onload = (event) => {
  1422. try {
  1423. const content = event.target?.result as string;
  1424. const parsed = JSON.parse(content);
  1425. // Handle both full preset format and settings-only format
  1426. const settings = parsed.setting || parsed;
  1427. setSettingsObj(prev => ({ ...prev, ...settings }));
  1428. setJsonText(JSON.stringify({ ...settingsObj, ...settings }, null, 2));
  1429. showToast(t('profiles.presets.fileImported'));
  1430. } catch {
  1431. showToast(t('profiles.presets.invalidJsonFile'), 'error');
  1432. }
  1433. };
  1434. reader.readAsText(file);
  1435. }
  1436. };
  1437. const createMutation = useMutation({
  1438. mutationFn: () => {
  1439. const finalSettings = { ...settingsObj };
  1440. const settingsIdKey = presetType === 'filament' ? 'filament_settings_id'
  1441. : presetType === 'print' ? 'print_settings_id' : 'printer_settings_id';
  1442. finalSettings[settingsIdKey] = `"${name}"`;
  1443. const data: SlicerSettingCreate = { type: presetType, name, base_id: baseId, setting: finalSettings };
  1444. return api.createCloudSetting(data);
  1445. },
  1446. onSuccess: async () => {
  1447. showToast(t('profiles.presets.toast.created'));
  1448. // Force immediate refetch of the settings list
  1449. await queryClient.refetchQueries({ queryKey: ['cloudSettings'] });
  1450. onClose();
  1451. },
  1452. onError: (error: Error) => showToast(error.message, 'error'),
  1453. });
  1454. const updateMutation = useMutation({
  1455. mutationFn: () => {
  1456. if (!initialData?.setting_id) throw new Error(t('profiles.presets.noSettingId'));
  1457. return api.updateCloudSetting(initialData.setting_id, { name, setting: settingsObj });
  1458. },
  1459. onSuccess: async () => {
  1460. showToast(t('profiles.presets.toast.updated'));
  1461. // Clear all detail caches to ensure fresh data
  1462. queryClient.removeQueries({ queryKey: ['cloudSettingDetail'] });
  1463. // Force immediate refetch of the settings list
  1464. await queryClient.refetchQueries({ queryKey: ['cloudSettings'] });
  1465. onClose();
  1466. },
  1467. onError: (error: Error) => showToast(error.message, 'error'),
  1468. });
  1469. const saveMutation = isEditMode ? updateMutation : createMutation;
  1470. // Check if base preset inherits from another preset (for user presets that only store overrides)
  1471. const inheritedPresetName = basePresetDetail?.setting?.inherits as string | undefined;
  1472. const inheritedPreset = inheritedPresetName
  1473. ? availableBasePresets.find(p => p.name === inheritedPresetName)
  1474. : undefined;
  1475. // Fetch the inherited preset's full values (if applicable)
  1476. const { data: inheritedPresetDetail } = useQuery<SlicerSettingDetail>({
  1477. queryKey: ['cloudSettingDetail', inheritedPreset?.setting_id],
  1478. queryFn: () => api.getCloudSettingDetail(inheritedPreset!.setting_id),
  1479. enabled: !!inheritedPreset?.setting_id,
  1480. });
  1481. // Get base preset values - merge inherited values with overrides
  1482. const basePresetValues = useMemo(() => {
  1483. // Start with inherited preset's values (full base)
  1484. const inheritedValues = inheritedPresetDetail?.setting as Record<string, unknown> || {};
  1485. // Get the selected preset's values (could be overrides only)
  1486. const selectedValues = basePresetDetail?.setting as Record<string, unknown> || {};
  1487. // Fallback to allPresetDetails if no dedicated query result
  1488. const fallbackValues = baseId && allPresetDetails?.[baseId]?.setting
  1489. ? allPresetDetails[baseId].setting as Record<string, unknown>
  1490. : {};
  1491. // Merge: inherited base values + selected preset overrides
  1492. // Selected values take precedence
  1493. return {
  1494. ...inheritedValues,
  1495. ...selectedValues,
  1496. ...fallbackValues,
  1497. };
  1498. }, [baseId, basePresetDetail, inheritedPresetDetail, allPresetDetails]);
  1499. // Format a value for display (handles arrays, objects, etc.)
  1500. const formatValue = (val: unknown): string => {
  1501. if (val === undefined || val === null) return '';
  1502. if (Array.isArray(val)) {
  1503. // For arrays, join with comma or take first value if all same
  1504. const unique = [...new Set(val.map(v => String(v)))];
  1505. return unique.length === 1 ? unique[0] : val.join(', ');
  1506. }
  1507. return String(val);
  1508. };
  1509. // Render a field input
  1510. const renderFieldInput = (field: FieldDefinition) => {
  1511. const value = settingsObj[field.key] as string | number | boolean | undefined;
  1512. const baseValue = basePresetValues[field.key];
  1513. const formattedBaseValue = formatValue(baseValue);
  1514. // Always show base value as placeholder when available
  1515. const placeholder = isLoadingBasePreset
  1516. ? t('common.loading')
  1517. : (formattedBaseValue || '');
  1518. const baseClass = "w-full px-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none";
  1519. if (field.type === 'boolean') {
  1520. const isOn = value === '1' || (value === undefined && baseValue === '1');
  1521. return (
  1522. <button
  1523. type="button"
  1524. onClick={() => updateField(field.key, value === '1' ? '0' : '1')}
  1525. className={`w-8 h-5 rounded-full transition-colors ${isOn ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'}`}
  1526. >
  1527. <div className={`w-4 h-4 rounded-full bg-white shadow transition-transform ${isOn ? 'translate-x-3.5' : 'translate-x-0.5'}`} />
  1528. </button>
  1529. );
  1530. }
  1531. if (field.type === 'select') {
  1532. return (
  1533. <select
  1534. value={(value as string) || ''}
  1535. onChange={(e) => updateField(field.key, e.target.value)}
  1536. className={baseClass}
  1537. >
  1538. <option value="">{placeholder}</option>
  1539. {field.options?.map(opt => (
  1540. <option key={opt.value} value={opt.value}>{opt.label}</option>
  1541. ))}
  1542. </select>
  1543. );
  1544. }
  1545. return (
  1546. <div className="flex items-center gap-2">
  1547. <input
  1548. type={field.type === 'number' ? 'number' : 'text'}
  1549. value={value !== undefined ? String(value) : ''}
  1550. onChange={(e) => updateField(field.key, e.target.value)}
  1551. step={field.step}
  1552. min={field.min}
  1553. max={field.max}
  1554. placeholder={placeholder}
  1555. className={baseClass}
  1556. />
  1557. {field.unit && <span className="text-xs text-bambu-gray whitespace-nowrap">{field.unit}</span>}
  1558. </div>
  1559. );
  1560. };
  1561. // Get base preset settings for diff comparison
  1562. const basePresetSettings = useMemo(() => {
  1563. if (!basePresetDetail?.setting) return {};
  1564. return basePresetDetail.setting as Record<string, unknown>;
  1565. }, [basePresetDetail]);
  1566. return (
  1567. <div
  1568. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  1569. onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
  1570. onDragLeave={() => setIsDragging(false)}
  1571. onDrop={handleFileDrop}
  1572. >
  1573. {/* Diff Modal */}
  1574. {showDiffModal && baseId && (
  1575. <DiffModal
  1576. onClose={() => setShowDiffModal(false)}
  1577. leftPreset={basePresetSettings}
  1578. rightPreset={settingsObj}
  1579. leftLabel={t('profiles.presets.baseLabel', { name: baseName || baseId })}
  1580. rightLabel={t('profiles.presets.currentLabel', { name: name || t('profiles.presets.newPreset') })}
  1581. t={t}
  1582. />
  1583. )}
  1584. <Card className="w-full max-w-6xl max-h-[90vh] flex flex-col overflow-y-auto">
  1585. <CardContent className="p-0 flex flex-col h-full">
  1586. {/* Header */}
  1587. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  1588. <div>
  1589. <h2 className="text-xl font-semibold text-white">
  1590. {isEditMode ? t('profiles.presets.editPreset') : (initialData ? t('profiles.presets.duplicatePreset') : t('profiles.presets.createNewPreset'))}
  1591. </h2>
  1592. <p className="text-sm text-bambu-gray mt-1">
  1593. {t('profiles.presets.customizeSettings')}
  1594. </p>
  1595. </div>
  1596. <div className="flex items-center gap-2">
  1597. {baseId && (
  1598. <button
  1599. onClick={() => setShowDiffModal(true)}
  1600. className="flex items-center gap-2 px-3 py-2 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  1601. title={t('profiles.presets.compareWithBase')}
  1602. >
  1603. <GitCompare className="w-4 h-4" />
  1604. {t('profiles.presets.compare')}
  1605. </button>
  1606. )}
  1607. <button onClick={onClose} className="p-2 text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
  1608. <X className="w-5 h-5" />
  1609. </button>
  1610. </div>
  1611. </div>
  1612. {/* Drag overlay */}
  1613. {isDragging && (
  1614. <div className="absolute inset-0 bg-bambu-green/10 border-2 border-dashed border-bambu-green rounded-lg flex items-center justify-center z-10">
  1615. <div className="text-center">
  1616. <Upload className="w-12 h-12 text-bambu-green mx-auto mb-2" />
  1617. <p className="text-bambu-green font-medium">{t('profiles.presets.dropJsonToImport')}</p>
  1618. </div>
  1619. </div>
  1620. )}
  1621. {/* Basic Info */}
  1622. <div className="p-4 border-b border-bambu-dark-tertiary space-y-3">
  1623. <div className="grid grid-cols-3 gap-4 max-[640px]:grid-cols-1">
  1624. <div>
  1625. <label className="block text-sm text-bambu-gray mb-1">{t('common.type')}</label>
  1626. <select
  1627. value={presetType}
  1628. onChange={(e) => { setPresetType(e.target.value as 'filament' | 'print' | 'printer'); setBaseId(''); }}
  1629. 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"
  1630. >
  1631. <option value="filament">{t('profiles.presets.types.filament')}</option>
  1632. <option value="print">{t('profiles.presets.types.process')}</option>
  1633. <option value="printer">{t('profiles.presets.types.printer')}</option>
  1634. </select>
  1635. </div>
  1636. <div>
  1637. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.presets.basePreset')}</label>
  1638. <select
  1639. value={baseId}
  1640. onChange={(e) => setBaseId(e.target.value)}
  1641. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  1642. >
  1643. <option value="">{t('profiles.presets.selectBasePreset')}</option>
  1644. {availableBasePresets.map((preset) => (
  1645. <option key={preset.setting_id} value={preset.setting_id}>{preset.name}</option>
  1646. ))}
  1647. </select>
  1648. </div>
  1649. <div>
  1650. <label className="block text-sm text-bambu-gray mb-1">{t('profiles.presets.presetName')}</label>
  1651. <input
  1652. type="text"
  1653. value={name}
  1654. onChange={(e) => setName(e.target.value)}
  1655. 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"
  1656. placeholder={t('profiles.presets.myCustomPreset')}
  1657. />
  1658. </div>
  1659. </div>
  1660. {baseName && (
  1661. <div className="text-xs text-bambu-gray">
  1662. <p className="flex items-center gap-1">
  1663. <Check className="w-3 h-3 text-bambu-green" />
  1664. {t('profiles.presets.inheritsFrom')} <span className="text-white">{baseName}</span>
  1665. {isLoadingBasePreset && (
  1666. <Loader2 className="w-3 h-3 animate-spin ml-1" />
  1667. )}
  1668. </p>
  1669. </div>
  1670. )}
  1671. </div>
  1672. {/* Tabs */}
  1673. <div className="flex border-b border-bambu-dark-tertiary max-[640px]:flex-wrap max-[640px]:items-center">
  1674. <button
  1675. onClick={() => setActiveTab('common')}
  1676. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  1677. activeTab === 'common' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'
  1678. }`}
  1679. >
  1680. <Sliders className="w-4 h-4" />
  1681. {t('profiles.presets.tabs.common')}
  1682. </button>
  1683. <button
  1684. onClick={() => setActiveTab('fields')}
  1685. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  1686. activeTab === 'fields' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'
  1687. }`}
  1688. >
  1689. <List className="w-4 h-4" />
  1690. {t('profiles.presets.tabs.allFields')}
  1691. </button>
  1692. <button
  1693. onClick={() => setActiveTab('json')}
  1694. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  1695. activeTab === 'json' ? 'text-bambu-green border-bambu-green' : 'text-bambu-gray hover:text-white border-transparent'
  1696. }`}
  1697. >
  1698. <Code className="w-4 h-4" />
  1699. JSON
  1700. {jsonError && <AlertCircle className="w-3 h-3 text-red-400" />}
  1701. </button>
  1702. <div className="flex-1 max-[640px]:hidden" />
  1703. <button
  1704. onClick={() => {
  1705. const exportData = {
  1706. name,
  1707. type: presetType,
  1708. base_id: baseId,
  1709. setting: settingsObj,
  1710. };
  1711. const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
  1712. const url = URL.createObjectURL(blob);
  1713. const a = document.createElement('a');
  1714. a.href = url;
  1715. a.download = `${name || 'preset'}.json`;
  1716. document.body.appendChild(a);
  1717. a.click();
  1718. document.body.removeChild(a);
  1719. URL.revokeObjectURL(url);
  1720. showToast(t('profiles.presets.toast.exported'));
  1721. }}
  1722. className="flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors"
  1723. title={t('profiles.presets.exportToJson')}
  1724. >
  1725. <Download className="w-4 h-4" />
  1726. {t('common.download')}
  1727. </button>
  1728. <button
  1729. onClick={() => document.getElementById('file-import')?.click()}
  1730. className="flex items-center gap-2 px-4 py-3 text-sm text-bambu-gray hover:text-white transition-colors"
  1731. title={t('profiles.presets.importFromJson')}
  1732. >
  1733. <Upload className="w-4 h-4" />
  1734. {t('common.upload')}
  1735. </button>
  1736. <input
  1737. id="file-import"
  1738. type="file"
  1739. accept=".json"
  1740. className="hidden"
  1741. onChange={(e) => {
  1742. const file = e.target.files?.[0];
  1743. if (file) {
  1744. const reader = new FileReader();
  1745. reader.onload = (event) => {
  1746. try {
  1747. const parsed = JSON.parse(event.target?.result as string);
  1748. const settings = parsed.setting || parsed;
  1749. setSettingsObj(prev => ({ ...prev, ...settings }));
  1750. showToast(t('profiles.presets.fileImported'));
  1751. } catch {
  1752. showToast(t('profiles.presets.invalidJson'), 'error');
  1753. }
  1754. };
  1755. reader.readAsText(file);
  1756. }
  1757. }}
  1758. />
  1759. </div>
  1760. {/* Tab Content */}
  1761. <div className="flex-1 p-4">
  1762. {activeTab === 'common' && (
  1763. <div className="space-y-6">
  1764. {/* Templates */}
  1765. <div>
  1766. <div className="flex items-center justify-between mb-3">
  1767. <h3 className="text-sm font-medium text-white flex items-center gap-2">
  1768. <Sparkles className="w-4 h-4 text-amber-400" />
  1769. {t('profiles.templates.title')}
  1770. </h3>
  1771. {Object.keys(settingsObj).filter(k => k !== 'inherits').length > 0 && (
  1772. <button
  1773. onClick={() => setShowSaveTemplate(!showSaveTemplate)}
  1774. className="text-xs text-bambu-gray hover:text-white flex items-center gap-1 transition-colors"
  1775. >
  1776. <Save className="w-3 h-3" />
  1777. {t('profiles.presets.saveAsTemplate')}
  1778. </button>
  1779. )}
  1780. </div>
  1781. {showSaveTemplate && (
  1782. <div className="mb-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1783. <div className="grid grid-cols-2 gap-2 mb-2">
  1784. <input
  1785. type="text"
  1786. value={newTemplateName}
  1787. onChange={(e) => setNewTemplateName(e.target.value)}
  1788. placeholder={t('profiles.templates.namePlaceholder')}
  1789. className="px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  1790. autoFocus
  1791. />
  1792. <input
  1793. type="text"
  1794. value={newTemplateDesc}
  1795. onChange={(e) => setNewTemplateDesc(e.target.value)}
  1796. placeholder={t('profiles.templates.descriptionPlaceholder')}
  1797. className="px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  1798. />
  1799. </div>
  1800. <div className="flex items-center justify-between">
  1801. <div className="flex gap-2">
  1802. <Button size="sm" onClick={saveAsTemplate} disabled={!newTemplateName.trim()}>
  1803. <Save className="w-3 h-3" />
  1804. {t('common.save')}
  1805. </Button>
  1806. <Button size="sm" variant="secondary" onClick={() => setShowSaveTemplate(false)}>
  1807. {t('common.cancel')}
  1808. </Button>
  1809. </div>
  1810. <button
  1811. onClick={() => setNewTemplateShowInModal(!newTemplateShowInModal)}
  1812. className={`flex items-center gap-1.5 text-xs transition-colors ${
  1813. newTemplateShowInModal ? 'text-bambu-green' : 'text-bambu-gray hover:text-white'
  1814. }`}
  1815. >
  1816. {newTemplateShowInModal ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
  1817. {newTemplateShowInModal ? t('profiles.templates.shownInModals') : t('profiles.templates.hiddenInModals')}
  1818. </button>
  1819. </div>
  1820. </div>
  1821. )}
  1822. {/* Applied template indicator */}
  1823. {appliedTemplateName && (
  1824. <div className="mb-3 px-3 py-2 bg-bambu-green/10 border border-bambu-green/30 rounded-lg flex items-center gap-2">
  1825. <Check className="w-4 h-4 text-bambu-green" />
  1826. <span className="text-sm text-bambu-green">{t('profiles.presets.templateApplied')} <span className="font-medium">{appliedTemplateName}</span></span>
  1827. <button
  1828. onClick={() => setAppliedTemplateName(null)}
  1829. className="ml-auto text-bambu-green/70 hover:text-bambu-green"
  1830. >
  1831. <X className="w-4 h-4" />
  1832. </button>
  1833. </div>
  1834. )}
  1835. <div className="grid grid-cols-3 gap-2">
  1836. {templatesForType.map((template) => (
  1837. <button
  1838. key={template.id}
  1839. onClick={() => applyTemplate(template)}
  1840. className="p-3 text-left bg-bambu-dark border border-bambu-dark-tertiary rounded-lg hover:border-bambu-gray-dark transition-colors"
  1841. >
  1842. <p className="text-sm font-medium text-white">{template.name}</p>
  1843. <p className="text-xs text-bambu-gray mt-1">{template.description}</p>
  1844. </button>
  1845. ))}
  1846. {templatesForType.length === 0 && (
  1847. <p className="col-span-3 text-center text-bambu-gray text-sm py-4">
  1848. {t('profiles.presets.noTemplatesSelected')}
  1849. </p>
  1850. )}
  1851. </div>
  1852. {/* Note about template management */}
  1853. <p className="text-xs text-bambu-gray-dark mt-2 text-center">
  1854. {t('profiles.presets.manageTemplatesHint')}
  1855. </p>
  1856. </div>
  1857. {/* Common Fields */}
  1858. <div>
  1859. <h3 className="text-sm font-medium text-white mb-3">{t('profiles.presets.commonSettings')}</h3>
  1860. <div className="grid grid-cols-2 gap-x-6 gap-y-3">
  1861. {dynamicFields.slice(0, 10).map(field => (
  1862. <div key={field.key} className="flex items-center justify-between gap-4 max-[640px]:flex-col max-[640px]:items-start">
  1863. <label className="text-sm text-bambu-gray flex-shrink-0">{field.label}</label>
  1864. <div className="w-48 max-[640px]:w-full">{renderFieldInput(field)}</div>
  1865. </div>
  1866. ))}
  1867. </div>
  1868. </div>
  1869. {/* Current overrides */}
  1870. {Object.keys(settingsObj).length > 1 && (
  1871. <div>
  1872. <h3 className="text-sm font-medium text-white mb-3">{t('profiles.presets.currentOverrides')}</h3>
  1873. <div className="flex flex-wrap gap-2">
  1874. {Object.entries(settingsObj)
  1875. .filter(([k]) => k !== 'inherits')
  1876. .map(([key, value]) => (
  1877. <span key={key} className="inline-flex items-center gap-1 px-2 py-1 bg-bambu-green/10 text-bambu-green text-xs rounded">
  1878. {key}: {String(value).slice(0, 20)}
  1879. <button onClick={() => updateField(key, undefined)} className="hover:text-white">
  1880. <X className="w-3 h-3" />
  1881. </button>
  1882. </span>
  1883. ))}
  1884. </div>
  1885. </div>
  1886. )}
  1887. </div>
  1888. )}
  1889. {activeTab === 'fields' && (
  1890. <div className="grid grid-cols-2 gap-6" style={{ height: '400px' }}>
  1891. {/* Left: Available Fields */}
  1892. <div className="flex flex-col h-full overflow-hidden">
  1893. <div className="flex items-center justify-between mb-3 flex-shrink-0">
  1894. <h3 className="text-sm font-medium text-white">{t('profiles.presets.availableFields')}</h3>
  1895. <span className="text-xs text-bambu-gray">
  1896. {allPresetDetails
  1897. ? t('profiles.templates.fieldsCount', { count: dynamicFields.length })
  1898. : t('common.loading')}
  1899. </span>
  1900. </div>
  1901. <div className="relative mb-3 flex-shrink-0">
  1902. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  1903. <input
  1904. type="text"
  1905. value={fieldSearch}
  1906. onChange={(e) => setFieldSearch(e.target.value)}
  1907. placeholder={t('profiles.presets.searchFieldsPlaceholder')}
  1908. className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
  1909. />
  1910. </div>
  1911. <div className="flex-1 overflow-y-auto space-y-1 pr-2 min-h-0">
  1912. {filteredFields
  1913. .filter(f => !(f.key in settingsObj))
  1914. .map(field => {
  1915. const baseVal = basePresetValues[field.key];
  1916. const formattedVal = formatValue(baseVal);
  1917. return (
  1918. <div
  1919. key={field.key}
  1920. onClick={() => {
  1921. // Add field directly (don't use updateField which deletes on empty)
  1922. setSettingsObj(prev => ({ ...prev, [field.key]: formattedVal || '' }));
  1923. }}
  1924. className="flex items-center justify-between gap-2 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors cursor-pointer group"
  1925. >
  1926. <div className="min-w-0 flex-1">
  1927. <p className="text-sm text-white truncate">{field.label}</p>
  1928. <p className="text-xs text-bambu-gray-dark truncate">{field.key}</p>
  1929. </div>
  1930. <div className="flex items-center gap-2 flex-shrink-0">
  1931. {formattedVal && (
  1932. <span className="text-xs text-bambu-gray bg-bambu-dark px-2 py-0.5 rounded max-w-32 truncate" title={formattedVal}>
  1933. {formattedVal.slice(0, 20)}{formattedVal.length > 20 ? '...' : ''}
  1934. </span>
  1935. )}
  1936. <div className="w-6 h-6 flex items-center justify-center rounded bg-bambu-dark-tertiary group-hover:bg-bambu-green/20 transition-colors">
  1937. <Plus className="w-4 h-4 text-bambu-gray group-hover:text-bambu-green transition-colors" />
  1938. </div>
  1939. </div>
  1940. </div>
  1941. );
  1942. })}
  1943. {filteredFields.filter(f => !(f.key in settingsObj)).length === 0 && (
  1944. <p className="text-center text-bambu-gray py-4 text-sm">
  1945. {fieldSearch ? t('profiles.presets.noMatchingFields') : t('profiles.presets.allFieldsAdded')}
  1946. </p>
  1947. )}
  1948. </div>
  1949. {/* Custom field input */}
  1950. <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0">
  1951. {showCustomFieldInput ? (
  1952. <div className="flex gap-2">
  1953. <input
  1954. type="text"
  1955. value={customFieldKey}
  1956. onChange={(e) => setCustomFieldKey(e.target.value)}
  1957. onKeyDown={(e) => e.key === 'Enter' && addCustomField()}
  1958. placeholder="custom_field_name"
  1959. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white font-mono text-sm placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
  1960. autoFocus
  1961. />
  1962. <Button size="sm" onClick={addCustomField} disabled={!customFieldKey.trim()}>
  1963. <Plus className="w-4 h-4" />
  1964. </Button>
  1965. <Button size="sm" variant="secondary" onClick={() => { setShowCustomFieldInput(false); setCustomFieldKey(''); }}>
  1966. <X className="w-4 h-4" />
  1967. </Button>
  1968. </div>
  1969. ) : (
  1970. <button
  1971. onClick={() => setShowCustomFieldInput(true)}
  1972. className="w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors"
  1973. >
  1974. <Plus className="w-4 h-4" />
  1975. {t('profiles.presets.addCustomField')}
  1976. </button>
  1977. )}
  1978. </div>
  1979. </div>
  1980. {/* Right: Added Fields */}
  1981. <div className="flex flex-col h-full overflow-hidden">
  1982. <div className="flex items-center justify-between mb-3 flex-shrink-0">
  1983. <h3 className="text-sm font-medium text-white">{t('profiles.presets.yourOverrides')}</h3>
  1984. <span className="text-xs text-bambu-gray">
  1985. {t('profiles.templates.fieldsCount', { count: Object.keys(settingsObj).filter(k => k !== 'inherits').length })}
  1986. </span>
  1987. </div>
  1988. <div className="flex-1 overflow-y-auto space-y-2 pr-2 min-h-0">
  1989. {Object.entries(settingsObj)
  1990. .filter(([key]) => key !== 'inherits')
  1991. .map(([key, value]) => {
  1992. const fieldDef = dynamicFields.find(f => f.key === key);
  1993. return (
  1994. <div key={key} className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1995. <div className="flex items-center justify-between mb-2">
  1996. <div>
  1997. <p className="text-sm font-medium text-white">{fieldDef?.label || key}</p>
  1998. <p className="text-xs text-bambu-gray-dark">{key}</p>
  1999. </div>
  2000. <button
  2001. onClick={() => updateField(key, undefined)}
  2002. className="p-1 text-bambu-gray hover:text-red-400 transition-colors"
  2003. >
  2004. <X className="w-4 h-4" />
  2005. </button>
  2006. </div>
  2007. {fieldDef ? (
  2008. renderFieldInput(fieldDef)
  2009. ) : (
  2010. <input
  2011. type="text"
  2012. value={String(value)}
  2013. onChange={(e) => updateField(key, e.target.value)}
  2014. className="w-full px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
  2015. />
  2016. )}
  2017. </div>
  2018. );
  2019. })}
  2020. {Object.keys(settingsObj).filter(k => k !== 'inherits').length === 0 && (
  2021. <div className="text-center py-8 text-bambu-gray">
  2022. <Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
  2023. <p className="text-sm">{t('profiles.presets.noOverridesYet')}</p>
  2024. <p className="text-xs mt-1">{t('profiles.presets.clickFieldsToAdd')}</p>
  2025. </div>
  2026. )}
  2027. </div>
  2028. {/* Save as template button */}
  2029. {Object.keys(settingsObj).filter(k => k !== 'inherits').length > 0 && (
  2030. <div className="pt-3 mt-3 border-t border-bambu-dark-tertiary flex-shrink-0">
  2031. <button
  2032. onClick={() => { setShowSaveTemplate(true); setActiveTab('common'); }}
  2033. className="w-full flex items-center justify-center gap-2 p-2 text-sm text-bambu-gray hover:text-white border border-dashed border-bambu-dark-tertiary hover:border-bambu-gray-dark rounded-lg transition-colors"
  2034. >
  2035. <Save className="w-4 h-4" />
  2036. {t('profiles.presets.saveAsTemplate')}
  2037. </button>
  2038. </div>
  2039. )}
  2040. </div>
  2041. </div>
  2042. )}
  2043. {activeTab === 'json' && (
  2044. <div className="space-y-2">
  2045. {jsonError && (
  2046. <div className="flex items-center gap-2 text-red-400 text-sm">
  2047. <AlertCircle className="w-4 h-4" />
  2048. {jsonError}
  2049. </div>
  2050. )}
  2051. <textarea
  2052. value={jsonText}
  2053. onChange={(e) => handleJsonChange(e.target.value)}
  2054. className={`w-full h-80 px-3 py-2 bg-bambu-dark border rounded-lg text-white text-xs font-mono focus:outline-none resize-none ${
  2055. jsonError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
  2056. }`}
  2057. spellCheck={false}
  2058. />
  2059. <p className="text-xs text-bambu-gray">
  2060. {t('profiles.presets.jsonTip')}
  2061. </p>
  2062. </div>
  2063. )}
  2064. </div>
  2065. {/* Footer */}
  2066. <div className="p-4 border-t border-bambu-dark-tertiary flex gap-2">
  2067. <Button variant="secondary" onClick={onClose} className="flex-1">{t('common.cancel')}</Button>
  2068. <Button
  2069. onClick={() => saveMutation.mutate()}
  2070. disabled={saveMutation.isPending || !name.trim() || (!isEditMode && !baseId) || !!jsonError}
  2071. className="flex-1"
  2072. >
  2073. {saveMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : (isEditMode ? <Save className="w-4 h-4" /> : <Plus className="w-4 h-4" />)}
  2074. {isEditMode ? t('common.save') : (initialData ? t('common.duplicate') : t('common.create'))}
  2075. </Button>
  2076. </div>
  2077. </CardContent>
  2078. </Card>
  2079. </div>
  2080. );
  2081. }
  2082. // ============================================================================
  2083. // CLOUD PROFILES VIEW
  2084. // ============================================================================
  2085. function CloudProfilesView({
  2086. settings,
  2087. lastSyncTime,
  2088. onRefresh,
  2089. isRefreshing,
  2090. printers,
  2091. hasPermission,
  2092. t,
  2093. }: {
  2094. settings: SlicerSettingsResponse;
  2095. lastSyncTime?: Date;
  2096. onRefresh: () => void;
  2097. isRefreshing: boolean;
  2098. printers: Printer[];
  2099. hasPermission: (permission: Permission) => boolean;
  2100. t: TFunction;
  2101. }) {
  2102. const [searchQuery, setSearchQuery] = useState('');
  2103. const [filterType, setFilterType] = useState<PresetType>('all');
  2104. const [filterOwner, setFilterOwner] = useState<'all' | 'custom' | 'builtin'>('all');
  2105. const [filterPrinter, setFilterPrinter] = useState('all');
  2106. const [filterNozzle, setFilterNozzle] = useState('all');
  2107. const [filterFilament, setFilterFilament] = useState('all');
  2108. const [filterLayerHeight, setFilterLayerHeight] = useState('all');
  2109. const [selectedSetting, setSelectedSetting] = useState<SlicerSetting | null>(null);
  2110. const [showCreateModal, setShowCreateModal] = useState(false);
  2111. const [showTemplatesModal, setShowTemplatesModal] = useState(false);
  2112. const [duplicateData, setDuplicateData] = useState<{ type: string; name: string; base_id: string; setting: Record<string, unknown> } | null>(null);
  2113. const [editData, setEditData] = useState<{ type: string; name: string; base_id: string; setting: Record<string, unknown>; setting_id: string } | null>(null);
  2114. const [templateData, setTemplateData] = useState<{ type: string; setting: Record<string, unknown> } | null>(null);
  2115. // Compare mode state
  2116. const [compareMode, setCompareMode] = useState(false);
  2117. const [compareSelection, setCompareSelection] = useState<[SlicerSetting | null, SlicerSetting | null]>([null, null]);
  2118. const [showCompareModal, setShowCompareModal] = useState(false);
  2119. const [comparePresets, setComparePresets] = useState<[Record<string, unknown>, Record<string, unknown>] | null>(null);
  2120. const queryClient = useQueryClient();
  2121. // Combine all presets with metadata
  2122. const allPresetsWithMeta = useMemo(() => {
  2123. const combined = [
  2124. ...settings.filament.map(s => ({ ...s, type: 'filament' as const })),
  2125. ...settings.printer.map(s => ({ ...s, type: 'printer' as const })),
  2126. ...settings.process.map(s => ({ ...s, type: 'process' as const })),
  2127. ];
  2128. return combined.map(s => ({ ...s, meta: extractMetadata(s.name) }));
  2129. }, [settings]);
  2130. // Extract unique filter values (use configured printers from API)
  2131. const filterOptions = useMemo(() => {
  2132. const nozzles = new Set<string>();
  2133. const filaments = new Set<string>();
  2134. const layerHeights = new Set<string>();
  2135. allPresetsWithMeta.forEach(p => {
  2136. if (p.meta.nozzle) nozzles.add(p.meta.nozzle);
  2137. if (p.meta.filamentType) filaments.add(p.meta.filamentType);
  2138. if (p.meta.layerHeight) layerHeights.add(p.meta.layerHeight);
  2139. });
  2140. return {
  2141. printers: printers.map(p => ({ id: p.id.toString(), name: p.name })),
  2142. nozzles: Array.from(nozzles).sort((a, b) => parseFloat(a) - parseFloat(b)),
  2143. filaments: Array.from(filaments).sort(),
  2144. layerHeights: Array.from(layerHeights).sort((a, b) => parseFloat(a) - parseFloat(b)),
  2145. };
  2146. }, [allPresetsWithMeta, printers]);
  2147. // Get selected printer's model for filtering
  2148. const selectedPrinterModel = useMemo(() => {
  2149. if (filterPrinter === 'all') return null;
  2150. const printer = printers.find(p => p.id.toString() === filterPrinter);
  2151. return printer?.model || null;
  2152. }, [filterPrinter, printers]);
  2153. // Apply filters
  2154. const filteredPresets = useMemo(() => {
  2155. return allPresetsWithMeta
  2156. .filter(s => filterType === 'all' || s.type === filterType)
  2157. .filter(s => {
  2158. if (filterOwner === 'all') return true;
  2159. const isCustom = isUserPreset(s.setting_id);
  2160. return filterOwner === 'custom' ? isCustom : !isCustom;
  2161. })
  2162. .filter(s => {
  2163. if (filterPrinter === 'all' || !selectedPrinterModel) return true;
  2164. // Match preset's printer model to configured printer's model
  2165. const presetPrinter = s.meta.printer?.toLowerCase() || '';
  2166. const configuredModel = selectedPrinterModel.toLowerCase();
  2167. return presetPrinter.includes(configuredModel) || configuredModel.includes(presetPrinter);
  2168. })
  2169. .filter(s => filterNozzle === 'all' || s.meta.nozzle === filterNozzle)
  2170. .filter(s => filterFilament === 'all' || s.meta.filamentType === filterFilament)
  2171. .filter(s => filterLayerHeight === 'all' || s.meta.layerHeight === filterLayerHeight)
  2172. .filter(s => searchQuery === '' || s.name.toLowerCase().includes(searchQuery.toLowerCase()))
  2173. .sort((a, b) => a.name.localeCompare(b.name));
  2174. }, [allPresetsWithMeta, filterType, filterOwner, filterPrinter, selectedPrinterModel, filterNozzle, filterFilament, filterLayerHeight, searchQuery]);
  2175. // Handle click on preset in compare mode
  2176. const handlePresetClick = (preset: SlicerSetting) => {
  2177. if (compareMode) {
  2178. // In compare mode, toggle selection
  2179. const isFirst = compareSelection[0]?.setting_id === preset.setting_id;
  2180. const isSecond = compareSelection[1]?.setting_id === preset.setting_id;
  2181. if (isFirst) {
  2182. // Deselect first
  2183. setCompareSelection([compareSelection[1], null]);
  2184. } else if (isSecond) {
  2185. // Deselect second
  2186. setCompareSelection([compareSelection[0], null]);
  2187. } else if (!compareSelection[0]) {
  2188. // Select as first
  2189. setCompareSelection([preset, null]);
  2190. } else if (!compareSelection[1]) {
  2191. // Check type match - only allow same type
  2192. if (compareSelection[0].type !== preset.type) {
  2193. return; // Don't allow selecting different types
  2194. }
  2195. // Select as second
  2196. setCompareSelection([compareSelection[0], preset]);
  2197. } else {
  2198. // Both selected, replace second (must match first's type)
  2199. if (compareSelection[0].type !== preset.type) {
  2200. return;
  2201. }
  2202. setCompareSelection([compareSelection[0], preset]);
  2203. }
  2204. } else {
  2205. // Normal mode, open detail
  2206. setSelectedSetting(preset);
  2207. }
  2208. };
  2209. // Check if preset is selected for comparison
  2210. const getCompareIndex = (preset: SlicerSetting): number | undefined => {
  2211. if (compareSelection[0]?.setting_id === preset.setting_id) return 0;
  2212. if (compareSelection[1]?.setting_id === preset.setting_id) return 1;
  2213. return undefined;
  2214. };
  2215. const handleDuplicate = async (setting: SlicerSetting) => {
  2216. try {
  2217. // Always fetch fresh data (bypass cache)
  2218. const detail = await api.getCloudSettingDetail(setting.setting_id);
  2219. const apiType = setting.type === 'process' ? 'print' : setting.type;
  2220. setDuplicateData({
  2221. type: apiType,
  2222. name: setting.name,
  2223. base_id: detail.base_id || 'GFSA00',
  2224. setting: detail.setting || {},
  2225. });
  2226. setSelectedSetting(null);
  2227. } catch (error) {
  2228. console.error('Failed to fetch preset details for duplication:', error);
  2229. }
  2230. };
  2231. const handleEdit = async (setting: SlicerSetting) => {
  2232. try {
  2233. // Clear any cached data first
  2234. queryClient.removeQueries({ queryKey: ['cloudSettingDetail', setting.setting_id] });
  2235. // Always fetch fresh data (bypass cache)
  2236. const detail = await api.getCloudSettingDetail(setting.setting_id);
  2237. const apiType = setting.type === 'process' ? 'print' : setting.type;
  2238. setEditData({
  2239. type: apiType,
  2240. name: setting.name,
  2241. base_id: detail.base_id || 'GFSA00',
  2242. setting: detail.setting || {},
  2243. setting_id: setting.setting_id,
  2244. });
  2245. setSelectedSetting(null);
  2246. } catch (error) {
  2247. console.error('Failed to fetch preset details for editing:', error);
  2248. }
  2249. };
  2250. const clearFilters = () => {
  2251. setFilterType('all');
  2252. setFilterOwner('all');
  2253. setFilterPrinter('all');
  2254. setFilterNozzle('all');
  2255. setFilterFilament('all');
  2256. setFilterLayerHeight('all');
  2257. setSearchQuery('');
  2258. };
  2259. const hasActiveFilters = filterType !== 'all' || filterOwner !== 'all' || filterPrinter !== 'all' || filterNozzle !== 'all' ||
  2260. filterFilament !== 'all' || filterLayerHeight !== 'all' || searchQuery !== '';
  2261. const totalCount = settings.filament.length + settings.printer.length + settings.process.length;
  2262. return (
  2263. <>
  2264. {/* Search and Filters */}
  2265. <div className="space-y-4 mb-6">
  2266. {/* Search row */}
  2267. <div className="flex flex-col sm:flex-row gap-3">
  2268. <div className="relative flex-1">
  2269. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  2270. <input
  2271. type="text"
  2272. value={searchQuery}
  2273. onChange={(e) => setSearchQuery(e.target.value)}
  2274. placeholder={t('profiles.cloudView.searchPlaceholder')}
  2275. className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none"
  2276. />
  2277. </div>
  2278. <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
  2279. <Button
  2280. variant={compareMode ? 'primary' : 'secondary'}
  2281. onClick={() => {
  2282. if (compareMode) {
  2283. setCompareMode(false);
  2284. setCompareSelection([null, null]);
  2285. } else {
  2286. setCompareMode(true);
  2287. }
  2288. }}
  2289. >
  2290. <GitCompare className="w-4 h-4" />
  2291. {compareMode ? t('common.cancel') : t('profiles.presets.compare')}
  2292. </Button>
  2293. <Button
  2294. variant="secondary"
  2295. onClick={() => setShowTemplatesModal(true)}
  2296. disabled={!hasPermission('cloud:auth')}
  2297. title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noTemplatesPermission') : undefined}
  2298. >
  2299. <Sparkles className="w-4 h-4" />
  2300. {t('profiles.cloudView.templates')}
  2301. </Button>
  2302. <Button
  2303. variant="secondary"
  2304. onClick={onRefresh}
  2305. disabled={isRefreshing || !hasPermission('cloud:auth')}
  2306. title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noRefreshPermission') : undefined}
  2307. >
  2308. <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
  2309. {t('profiles.cloudView.refresh')}
  2310. </Button>
  2311. <Button
  2312. onClick={() => setShowCreateModal(true)}
  2313. disabled={!hasPermission('cloud:auth')}
  2314. title={!hasPermission('cloud:auth') ? t('profiles.cloudView.noCreatePermission') : undefined}
  2315. >
  2316. <Plus className="w-4 h-4" />
  2317. {t('profiles.cloudView.newPreset')}
  2318. </Button>
  2319. </div>
  2320. </div>
  2321. {/* Filter row */}
  2322. <div className="flex flex-wrap items-center gap-2">
  2323. <Filter className="w-4 h-4 text-bambu-gray" />
  2324. <FilterDropdown
  2325. label={t('profiles.cloudView.filters.type')}
  2326. value={filterType}
  2327. options={[
  2328. { value: 'all', label: t('profiles.cloudView.filters.all'), count: totalCount },
  2329. { value: 'filament', label: t('profiles.cloudView.filters.filament'), count: settings.filament.length },
  2330. { value: 'printer', label: t('profiles.cloudView.filters.printer'), count: settings.printer.length },
  2331. { value: 'process', label: t('profiles.cloudView.filters.process'), count: settings.process.length },
  2332. ]}
  2333. onChange={(v) => setFilterType(v as PresetType)}
  2334. />
  2335. <FilterDropdown
  2336. label={t('profiles.cloudView.filters.owner')}
  2337. value={filterOwner}
  2338. options={[
  2339. { value: 'all', label: t('profiles.cloudView.filters.all') },
  2340. { value: 'custom', label: t('profiles.cloudView.filters.myPresets') },
  2341. { value: 'builtin', label: t('profiles.cloudView.filters.builtIn') },
  2342. ]}
  2343. onChange={(v) => setFilterOwner(v as 'all' | 'custom' | 'builtin')}
  2344. />
  2345. {filterOptions.printers.length > 0 && (
  2346. <FilterDropdown
  2347. label={t('profiles.cloudView.filters.printer')}
  2348. value={filterPrinter}
  2349. options={[
  2350. { value: 'all', label: t('profiles.cloudView.filters.all') },
  2351. ...filterOptions.printers.map(p => ({ value: p.id, label: p.name })),
  2352. ]}
  2353. onChange={setFilterPrinter}
  2354. />
  2355. )}
  2356. {filterOptions.nozzles.length > 0 && (
  2357. <FilterDropdown
  2358. label={t('profiles.cloudView.filters.nozzle')}
  2359. value={filterNozzle}
  2360. options={[
  2361. { value: 'all', label: t('profiles.cloudView.filters.all') },
  2362. ...filterOptions.nozzles.map(n => ({ value: n, label: n })),
  2363. ]}
  2364. onChange={setFilterNozzle}
  2365. />
  2366. )}
  2367. {filterOptions.filaments.length > 0 && (filterType === 'all' || filterType === 'filament') && (
  2368. <FilterDropdown
  2369. label={t('profiles.cloudView.filters.filament')}
  2370. value={filterFilament}
  2371. options={[
  2372. { value: 'all', label: t('profiles.cloudView.filters.all') },
  2373. ...filterOptions.filaments.map(f => ({ value: f, label: f })),
  2374. ]}
  2375. onChange={setFilterFilament}
  2376. />
  2377. )}
  2378. {filterOptions.layerHeights.length > 0 && (filterType === 'all' || filterType === 'process') && (
  2379. <FilterDropdown
  2380. label={t('profiles.cloudView.filters.layer')}
  2381. value={filterLayerHeight}
  2382. options={[
  2383. { value: 'all', label: t('profiles.cloudView.filters.all') },
  2384. ...filterOptions.layerHeights.map(l => ({ value: l, label: l })),
  2385. ]}
  2386. onChange={setFilterLayerHeight}
  2387. />
  2388. )}
  2389. {hasActiveFilters && (
  2390. <button
  2391. onClick={clearFilters}
  2392. className="px-3 py-2 text-sm text-bambu-gray hover:text-white transition-colors"
  2393. >
  2394. {t('profiles.cloudView.clearFilters')}
  2395. </button>
  2396. )}
  2397. </div>
  2398. </div>
  2399. {/* Compare mode bar */}
  2400. {compareMode && (
  2401. <div className="mb-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
  2402. <div className="flex items-center justify-between">
  2403. <div className="flex items-center gap-4">
  2404. <GitCompare className="w-5 h-5 text-blue-400" />
  2405. <span className="text-white font-medium">{t('profiles.cloudView.compareMode')}</span>
  2406. <span className="text-bambu-gray">
  2407. {compareSelection[0]
  2408. ? t('profiles.cloudView.selectAnotherPreset', { type: compareSelection[0].type })
  2409. : t('profiles.cloudView.clickTwoPresets')}
  2410. </span>
  2411. </div>
  2412. <div className="flex items-center gap-3">
  2413. <div className="flex items-center gap-2">
  2414. <span className={`px-2 py-1 text-sm rounded truncate max-w-[200px] ${compareSelection[0] ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-bambu-dark text-bambu-gray'}`}>
  2415. {compareSelection[0] ? compareSelection[0].name : t('profiles.cloudView.selectFirst')}
  2416. </span>
  2417. <ArrowRight className="w-4 h-4 text-bambu-gray" />
  2418. <span className={`px-2 py-1 text-sm rounded truncate max-w-[200px] ${compareSelection[1] ? 'bg-blue-500/30 text-blue-700 dark:text-blue-300' : 'bg-bambu-dark text-bambu-gray'}`}>
  2419. {compareSelection[1] ? compareSelection[1].name : t('profiles.cloudView.selectSecond')}
  2420. </span>
  2421. </div>
  2422. {compareSelection[0] && compareSelection[1] && (
  2423. <Button
  2424. size="sm"
  2425. onClick={async () => {
  2426. try {
  2427. const [left, right] = await Promise.all([
  2428. api.getCloudSettingDetail(compareSelection[0]!.setting_id),
  2429. api.getCloudSettingDetail(compareSelection[1]!.setting_id),
  2430. ]);
  2431. setComparePresets([
  2432. (left.setting || {}) as Record<string, unknown>,
  2433. (right.setting || {}) as Record<string, unknown>,
  2434. ]);
  2435. setShowCompareModal(true);
  2436. } catch {
  2437. // Handle error silently
  2438. }
  2439. }}
  2440. >
  2441. <GitCompare className="w-4 h-4" />
  2442. {t('profiles.cloudView.compareNow')}
  2443. </Button>
  2444. )}
  2445. </div>
  2446. </div>
  2447. </div>
  2448. )}
  2449. {/* Status row: sync time, count, and legend */}
  2450. <div className="flex flex-wrap items-center gap-4 mb-4 text-sm text-bambu-gray">
  2451. {lastSyncTime && (
  2452. <div className="flex items-center gap-1">
  2453. <Clock className="w-3 h-3" />
  2454. {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
  2455. </div>
  2456. )}
  2457. <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>
  2458. <div className="flex items-center gap-1">
  2459. <span className="w-1.5 h-1.5 rounded-full bg-bambu-green" />
  2460. <span>= {t('profiles.presets.myPreset')}</span>
  2461. </div>
  2462. </div>
  2463. {/* 3-Column Presets List */}
  2464. {filteredPresets.length === 0 ? (
  2465. <div className="text-center py-16">
  2466. <Layers className="w-12 h-12 text-bambu-gray-dark mx-auto mb-4" />
  2467. <p className="text-bambu-gray">{t('profiles.cloudView.noPresetsFound')}</p>
  2468. {hasActiveFilters && (
  2469. <button onClick={clearFilters} className="mt-2 text-sm text-bambu-green hover:text-bambu-green-light">
  2470. {t('profiles.cloudView.clearFilters')}
  2471. </button>
  2472. )}
  2473. </div>
  2474. ) : (
  2475. <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
  2476. {/* Filament Column */}
  2477. <div>
  2478. <div className="flex items-center gap-2 mb-3 px-1">
  2479. <Droplet className="w-4 h-4 text-amber-400" />
  2480. <h3 className="text-sm font-medium text-bambu-gray">{t('profiles.cloudView.columns.filament')}</h3>
  2481. <span className="text-xs text-bambu-gray-dark">
  2482. ({filteredPresets.filter(p => p.type === 'filament').length})
  2483. </span>
  2484. </div>
  2485. <div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
  2486. {filteredPresets
  2487. .filter(p => p.type === 'filament')
  2488. .map((preset) => (
  2489. <PresetListItem
  2490. key={preset.setting_id}
  2491. setting={preset}
  2492. onClick={() => handlePresetClick(preset)}
  2493. onDuplicate={() => handleDuplicate(preset)}
  2494. compareMode={compareMode}
  2495. isCompareSelected={getCompareIndex(preset) !== undefined}
  2496. compareIndex={getCompareIndex(preset)}
  2497. compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}
  2498. t={t}
  2499. />
  2500. ))}
  2501. {filteredPresets.filter(p => p.type === 'filament').length === 0 && (
  2502. <p className="text-xs text-bambu-gray-dark px-3 py-2">{t('profiles.cloudView.noFilamentPresets')}</p>
  2503. )}
  2504. </div>
  2505. </div>
  2506. {/* Process Column */}
  2507. <div>
  2508. <div className="flex items-center gap-2 mb-3 px-1">
  2509. <Settings2 className="w-4 h-4 text-blue-400" />
  2510. <h3 className="text-sm font-medium text-bambu-gray">{t('profiles.cloudView.columns.process')}</h3>
  2511. <span className="text-xs text-bambu-gray-dark">
  2512. ({filteredPresets.filter(p => p.type === 'process').length})
  2513. </span>
  2514. </div>
  2515. <div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
  2516. {filteredPresets
  2517. .filter(p => p.type === 'process')
  2518. .map((preset) => (
  2519. <PresetListItem
  2520. key={preset.setting_id}
  2521. setting={preset}
  2522. onClick={() => handlePresetClick(preset)}
  2523. onDuplicate={() => handleDuplicate(preset)}
  2524. compareMode={compareMode}
  2525. isCompareSelected={getCompareIndex(preset) !== undefined}
  2526. compareIndex={getCompareIndex(preset)}
  2527. compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}
  2528. t={t}
  2529. />
  2530. ))}
  2531. {filteredPresets.filter(p => p.type === 'process').length === 0 && (
  2532. <p className="text-xs text-bambu-gray-dark px-3 py-2">{t('profiles.cloudView.noProcessPresets')}</p>
  2533. )}
  2534. </div>
  2535. </div>
  2536. {/* Printer Column */}
  2537. <div>
  2538. <div className="flex items-center gap-2 mb-3 px-1">
  2539. <PrinterIcon className="w-4 h-4 text-purple-400" />
  2540. <h3 className="text-sm font-medium text-bambu-gray">{t('profiles.cloudView.columns.printer')}</h3>
  2541. <span className="text-xs text-bambu-gray-dark">
  2542. ({filteredPresets.filter(p => p.type === 'printer').length})
  2543. </span>
  2544. </div>
  2545. <div className="space-y-1 max-h-[calc(100vh-320px)] overflow-y-auto pr-1">
  2546. {filteredPresets
  2547. .filter(p => p.type === 'printer')
  2548. .map((preset) => (
  2549. <PresetListItem
  2550. key={preset.setting_id}
  2551. setting={preset}
  2552. onClick={() => handlePresetClick(preset)}
  2553. onDuplicate={() => handleDuplicate(preset)}
  2554. compareMode={compareMode}
  2555. isCompareSelected={getCompareIndex(preset) !== undefined}
  2556. compareIndex={getCompareIndex(preset)}
  2557. compareDisabled={compareMode && !!compareSelection[0] && compareSelection[0].type !== preset.type}
  2558. t={t}
  2559. />
  2560. ))}
  2561. {filteredPresets.filter(p => p.type === 'printer').length === 0 && (
  2562. <p className="text-xs text-bambu-gray-dark px-3 py-2">{t('profiles.cloudView.noPrinterPresets')}</p>
  2563. )}
  2564. </div>
  2565. </div>
  2566. </div>
  2567. )}
  2568. {/* Modals */}
  2569. {selectedSetting && (
  2570. <PresetDetailModal
  2571. setting={selectedSetting}
  2572. onClose={() => setSelectedSetting(null)}
  2573. onDeleted={() => setSelectedSetting(null)}
  2574. onDuplicate={() => handleDuplicate(selectedSetting)}
  2575. onEdit={() => handleEdit(selectedSetting)}
  2576. hasPermission={hasPermission}
  2577. t={t}
  2578. />
  2579. )}
  2580. {(showCreateModal || duplicateData || editData || templateData) && (
  2581. <CreatePresetModal
  2582. onClose={() => { setShowCreateModal(false); setDuplicateData(null); setEditData(null); setTemplateData(null); }}
  2583. initialData={editData || duplicateData || (templateData ? { type: templateData.type, name: '', base_id: '', setting: templateData.setting } : undefined)}
  2584. allPresets={settings}
  2585. t={t}
  2586. />
  2587. )}
  2588. {showTemplatesModal && (
  2589. <TemplatesModal
  2590. onClose={() => setShowTemplatesModal(false)}
  2591. onApply={(template) => {
  2592. setTemplateData({ type: template.type, setting: template.settings });
  2593. setShowTemplatesModal(false);
  2594. }}
  2595. t={t}
  2596. />
  2597. )}
  2598. {showCompareModal && comparePresets && compareSelection[0] && compareSelection[1] && (
  2599. <DiffModal
  2600. onClose={() => {
  2601. setShowCompareModal(false);
  2602. setComparePresets(null);
  2603. }}
  2604. leftPreset={comparePresets[0]}
  2605. rightPreset={comparePresets[1]}
  2606. leftLabel={compareSelection[0].name}
  2607. rightLabel={compareSelection[1].name}
  2608. t={t}
  2609. />
  2610. )}
  2611. </>
  2612. );
  2613. }
  2614. // ============================================================================
  2615. // MAIN PAGE
  2616. // ============================================================================
  2617. export function ProfilesPage() {
  2618. const { t } = useTranslation();
  2619. const queryClient = useQueryClient();
  2620. const { showToast } = useToast();
  2621. const { hasPermission } = useAuth();
  2622. const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
  2623. const [lastSyncTime, setLastSyncTime] = useState<Date>();
  2624. const { data: status, isLoading: statusLoading } = useQuery({
  2625. queryKey: ['cloudStatus'],
  2626. queryFn: api.getCloudStatus,
  2627. });
  2628. const { data: printers = [] } = useQuery({
  2629. queryKey: ['printers'],
  2630. queryFn: api.getPrinters,
  2631. });
  2632. const { data: settings, isLoading: settingsLoading, refetch: refetchSettings, dataUpdatedAt } = useQuery({
  2633. queryKey: ['cloudSettings'],
  2634. queryFn: () => api.getCloudSettings(),
  2635. enabled: !!status?.is_authenticated,
  2636. retry: false,
  2637. staleTime: 1000 * 60 * 5,
  2638. });
  2639. useEffect(() => {
  2640. if (dataUpdatedAt) {
  2641. setLastSyncTime(new Date(dataUpdatedAt));
  2642. }
  2643. }, [dataUpdatedAt]);
  2644. const logoutMutation = useMutation({
  2645. mutationFn: api.cloudLogout,
  2646. onSuccess: () => {
  2647. queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
  2648. queryClient.removeQueries({ queryKey: ['cloudSettings'] });
  2649. showToast(t('profiles.toast.loggedOut'));
  2650. },
  2651. });
  2652. const handleLoginSuccess = () => {
  2653. queryClient.invalidateQueries({ queryKey: ['cloudStatus'] });
  2654. };
  2655. if (statusLoading) {
  2656. return (
  2657. <div className="p-4 md:p-8 flex items-center justify-center min-h-[400px]">
  2658. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  2659. </div>
  2660. );
  2661. }
  2662. return (
  2663. <div className="p-4 md:p-8">
  2664. {/* Page Header */}
  2665. <div className="mb-6">
  2666. <h1 className="text-2xl font-bold text-white flex items-center gap-3">
  2667. <Cloud className="w-7 h-7 text-bambu-green" />
  2668. {t('profiles.title')}
  2669. </h1>
  2670. <p className="text-bambu-gray mt-1">{t('profiles.subtitle')}</p>
  2671. </div>
  2672. {/* Tab Navigation */}
  2673. <div className="flex border-b border-bambu-dark-tertiary mb-6">
  2674. <button
  2675. onClick={() => setActiveTab('cloud')}
  2676. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  2677. activeTab === 'cloud'
  2678. ? 'text-bambu-green border-bambu-green'
  2679. : 'text-bambu-gray hover:text-white border-transparent'
  2680. }`}
  2681. >
  2682. <Cloud className="w-4 h-4" />
  2683. {t('profiles.tabs.cloud')}
  2684. </button>
  2685. <button
  2686. onClick={() => setActiveTab('local')}
  2687. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  2688. activeTab === 'local'
  2689. ? 'text-bambu-green border-bambu-green'
  2690. : 'text-bambu-gray hover:text-white border-transparent'
  2691. }`}
  2692. >
  2693. <HardDrive className="w-4 h-4" />
  2694. {t('profiles.tabs.local')}
  2695. </button>
  2696. <button
  2697. onClick={() => setActiveTab('kprofiles')}
  2698. className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
  2699. activeTab === 'kprofiles'
  2700. ? 'text-bambu-green border-bambu-green'
  2701. : 'text-bambu-gray hover:text-white border-transparent'
  2702. }`}
  2703. >
  2704. <Gauge className="w-4 h-4" />
  2705. {t('profiles.tabs.kprofiles')}
  2706. </button>
  2707. </div>
  2708. {/* Cloud Profiles Tab */}
  2709. {activeTab === 'cloud' && (
  2710. <>
  2711. {/* Connection Status Bar */}
  2712. {status?.is_authenticated && (
  2713. <div className="flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  2714. <div className="flex items-center gap-3">
  2715. <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
  2716. <span className="text-sm text-bambu-gray">
  2717. {t('profiles.connectedAs')} <span className="text-white">{status.email}</span>
  2718. {status.region && (
  2719. <span className="ml-2 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
  2720. {status.region === 'china'
  2721. ? t('profiles.login.regionChina')
  2722. : t('profiles.login.regionGlobal')}
  2723. </span>
  2724. )}
  2725. </span>
  2726. </div>
  2727. <Button
  2728. variant="secondary"
  2729. size="sm"
  2730. onClick={() => logoutMutation.mutate()}
  2731. disabled={logoutMutation.isPending || !hasPermission('cloud:auth')}
  2732. title={!hasPermission('cloud:auth') ? t('profiles.noLogoutPermission') : undefined}
  2733. >
  2734. <LogOut className="w-4 h-4" />
  2735. {t('profiles.logout')}
  2736. </Button>
  2737. </div>
  2738. )}
  2739. {!status?.is_authenticated ? (
  2740. <LoginForm onSuccess={handleLoginSuccess} t={t} />
  2741. ) : settingsLoading ? (
  2742. <div className="flex items-center justify-center py-16">
  2743. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  2744. </div>
  2745. ) : settings ? (
  2746. <CloudProfilesView
  2747. settings={settings}
  2748. lastSyncTime={lastSyncTime}
  2749. onRefresh={() => refetchSettings()}
  2750. isRefreshing={settingsLoading}
  2751. printers={printers}
  2752. hasPermission={hasPermission}
  2753. t={t}
  2754. />
  2755. ) : (
  2756. <div className="text-center py-16">
  2757. <p className="text-bambu-gray mb-4">{t('profiles.failedToLoad')}</p>
  2758. <Button onClick={() => refetchSettings()}>{t('profiles.retry')}</Button>
  2759. </div>
  2760. )}
  2761. </>
  2762. )}
  2763. {/* Local Profiles Tab */}
  2764. {activeTab === 'local' && <LocalProfilesView />}
  2765. {/* K-Profiles Tab */}
  2766. {activeTab === 'kprofiles' && <KProfilesView />}
  2767. {/* Scroll to Top Button */}
  2768. <ScrollToTop />
  2769. </div>
  2770. );
  2771. }