ProfilesPage.tsx 128 KB

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