ProfilesPage.tsx 124 KB

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