ProfilesPage.tsx 126 KB

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