ProfilesPage.tsx 117 KB

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