ConfigureAmsSlotModal.tsx 78 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774
  1. import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
  2. import { useQuery, useMutation } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { KProfile } from '../api/client';
  7. import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
  8. import { toFilamentId } from './spool-form/utils';
  9. import { Button } from './Button';
  10. import { getAmsLabel } from '../utils/amsHelpers';
  11. interface SlotInfo {
  12. amsId: number;
  13. trayId: number;
  14. trayCount: number;
  15. trayType?: string;
  16. trayColor?: string;
  17. traySubBrands?: string;
  18. trayInfoIdx?: string;
  19. extruderId?: number;
  20. caliIdx?: number | null;
  21. savedPresetId?: string;
  22. }
  23. // Convert setting_id to tray_info_idx (filament_id format)
  24. // Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
  25. function convertToTrayInfoIdx(settingId: string): string {
  26. // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
  27. const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
  28. // Bambu presets start with "GFS" - remove the 'S' to get filament_id
  29. if (baseId.startsWith('GFS')) {
  30. return 'GF' + baseId.slice(3);
  31. }
  32. // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
  33. // This follows the pattern that filament_id and setting_id share the same base ID
  34. if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
  35. return baseId; // Use base ID without version suffix
  36. }
  37. // For other formats, use as-is
  38. return baseId;
  39. }
  40. interface ConfigureAmsSlotModalProps {
  41. isOpen: boolean;
  42. onClose: () => void;
  43. printerId: number;
  44. slotInfo: SlotInfo;
  45. nozzleDiameter?: string;
  46. printerModel?: string;
  47. onSuccess?: () => void;
  48. fullScreen?: boolean;
  49. }
  50. // Known filament material types
  51. const MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];
  52. // Extract filament type from preset name by finding known material type
  53. function parsePresetName(name: string): { material: string; brand: string; variant: string } {
  54. // Remove printer/nozzle suffix first
  55. const withoutSuffix = name.replace(/@.+$/, '').trim();
  56. const upperName = withoutSuffix.toUpperCase();
  57. // Handle "X Support for Y" pattern: the filament type is Y, not X.
  58. // e.g. "PLA Support for PETG PETG Basic" → material is PETG
  59. const supportMatch = upperName.match(/\bSUPPORT\s+FOR\s+/);
  60. if (supportMatch) {
  61. const afterSupport = upperName.slice(supportMatch.index! + supportMatch[0].length);
  62. for (const mat of MATERIAL_TYPES) {
  63. const regex = new RegExp(`\\b${mat}\\b`);
  64. if (regex.test(afterSupport)) {
  65. const brand = withoutSuffix.slice(0, supportMatch.index).trim();
  66. return { material: mat, brand, variant: 'Support' };
  67. }
  68. }
  69. }
  70. // Try to find a known material type in the name
  71. for (const mat of MATERIAL_TYPES) {
  72. // Use word boundary to match whole words only
  73. const regex = new RegExp(`\\b${mat}\\b`, 'i');
  74. if (regex.test(upperName)) {
  75. // Found material, extract brand (everything before material) and variant (after)
  76. const parts = withoutSuffix.split(regex);
  77. const brand = parts[0]?.trim() || '';
  78. const variant = parts[1]?.trim() || '';
  79. return { material: mat, brand, variant };
  80. }
  81. }
  82. // Fallback: assume first word is brand, second is material
  83. const parts = withoutSuffix.split(/\s+/);
  84. if (parts.length >= 2) {
  85. return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };
  86. }
  87. return { material: withoutSuffix, brand: '', variant: '' };
  88. }
  89. // Identity of a K-profile inside the picker. Both profile lists are
  90. // deduplicated on name+k_value, so this is unique across the whole option set
  91. // — unlike the bare name, which two profiles can share (#2710).
  92. function kProfileOptionValue(profile: KProfile): string {
  93. return `${profile.name}|${profile.k_value}`;
  94. }
  95. // Check if a preset is a user preset (not built-in)
  96. function isUserPreset(settingId: string): boolean {
  97. // Built-in presets have specific patterns, user presets are UUIDs
  98. return !settingId.startsWith('GF') && !settingId.startsWith('P1');
  99. }
  100. // Common color name to hex mapping
  101. const COLOR_NAME_MAP: Record<string, string> = {
  102. // Basic colors
  103. 'white': 'FFFFFF',
  104. 'black': '000000',
  105. 'red': 'FF0000',
  106. 'green': '00FF00',
  107. 'blue': '0000FF',
  108. 'yellow': 'FFFF00',
  109. 'cyan': '00FFFF',
  110. 'magenta': 'FF00FF',
  111. 'orange': 'FFA500',
  112. 'purple': '800080',
  113. 'pink': 'FFC0CB',
  114. 'brown': '8B4513',
  115. 'gray': '808080',
  116. 'grey': '808080',
  117. // Filament-specific colors
  118. 'jade white': 'FFFEF2',
  119. 'ivory': 'FFFFF0',
  120. 'beige': 'F5F5DC',
  121. 'cream': 'FFFDD0',
  122. 'silver': 'C0C0C0',
  123. 'gold': 'FFD700',
  124. 'bronze': 'CD7F32',
  125. 'copper': 'B87333',
  126. 'navy': '000080',
  127. 'teal': '008080',
  128. 'olive': '808000',
  129. 'maroon': '800000',
  130. 'coral': 'FF7F50',
  131. 'salmon': 'FA8072',
  132. 'lime': '32CD32',
  133. 'mint': '98FF98',
  134. 'forest green': '228B22',
  135. 'sky blue': '87CEEB',
  136. 'royal blue': '4169E1',
  137. 'turquoise': '40E0D0',
  138. 'lavender': 'E6E6FA',
  139. 'violet': 'EE82EE',
  140. 'plum': 'DDA0DD',
  141. 'tan': 'D2B48C',
  142. 'chocolate': 'D2691E',
  143. 'charcoal': '36454F',
  144. 'slate': '708090',
  145. 'transparent': '000000', // Will need special handling
  146. 'natural': 'F5F5DC',
  147. 'wood': 'DEB887',
  148. };
  149. // Quick-select color presets (common filament colors)
  150. // Basic colors shown by default
  151. const QUICK_COLORS_BASIC = [
  152. { name: 'White', hex: 'FFFFFF' },
  153. { name: 'Black', hex: '000000' },
  154. { name: 'Red', hex: 'FF0000' },
  155. { name: 'Blue', hex: '0000FF' },
  156. { name: 'Green', hex: '00AA00' },
  157. { name: 'Yellow', hex: 'FFFF00' },
  158. { name: 'Orange', hex: 'FFA500' },
  159. { name: 'Gray', hex: '808080' },
  160. ];
  161. // Extended colors shown when expanded
  162. const QUICK_COLORS_EXTENDED = [
  163. { name: 'Cyan', hex: '00FFFF' },
  164. { name: 'Magenta', hex: 'FF00FF' },
  165. { name: 'Purple', hex: '800080' },
  166. { name: 'Pink', hex: 'FFC0CB' },
  167. { name: 'Brown', hex: '8B4513' },
  168. { name: 'Beige', hex: 'F5F5DC' },
  169. { name: 'Navy', hex: '000080' },
  170. { name: 'Teal', hex: '008080' },
  171. { name: 'Lime', hex: '32CD32' },
  172. { name: 'Gold', hex: 'FFD700' },
  173. { name: 'Silver', hex: 'C0C0C0' },
  174. { name: 'Maroon', hex: '800000' },
  175. { name: 'Olive', hex: '808000' },
  176. { name: 'Coral', hex: 'FF7F50' },
  177. { name: 'Salmon', hex: 'FA8072' },
  178. { name: 'Turquoise', hex: '40E0D0' },
  179. { name: 'Violet', hex: 'EE82EE' },
  180. { name: 'Indigo', hex: '4B0082' },
  181. { name: 'Chocolate', hex: 'D2691E' },
  182. { name: 'Tan', hex: 'D2B48C' },
  183. { name: 'Slate', hex: '708090' },
  184. { name: 'Charcoal', hex: '36454F' },
  185. { name: 'Ivory', hex: 'FFFFF0' },
  186. { name: 'Cream', hex: 'FFFDD0' },
  187. ];
  188. // Try to convert color name to hex
  189. function colorNameToHex(name: string): string | null {
  190. const normalized = name.toLowerCase().trim();
  191. return COLOR_NAME_MAP[normalized] || null;
  192. }
  193. // Escape regex metacharacters and turn whitespace into ``\s+`` so a literal
  194. // model token compiles to a flexible-whitespace word-boundary regex.
  195. function _tokenToRegex(token: string): RegExp {
  196. const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
  197. return new RegExp(`\\b${escaped}\\b`, 'i');
  198. }
  199. // Extract printer model from a preset name → normalized short code
  200. // (e.g. "X1C", "H2D"). Two strategies in order:
  201. //
  202. // (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
  203. // - "@BBL X1C 0.4 nozzle" → "X1C" (short-code form,
  204. // Bambu Cloud system presets)
  205. // - "@Bambu Lab X1 Carbon 0.4 nozzle" → "X1C" (long-form, used by
  206. // user-renamed Bambu Cloud presets and most Orca Cloud profiles —
  207. // reverse-looked-up via the backend printer-model registry)
  208. //
  209. // (2) Body scan — many user-authored / Orca Cloud presets put the printer
  210. // model at the START of the name with no @ suffix at all (the literal
  211. // shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
  212. // name for any known model token (every long-name fragment + every short
  213. // code from the registry) and return the first match. Long-first sort
  214. // keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
  215. // shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
  216. // false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
  217. // "X1Box" doesn't match "X1").
  218. //
  219. // Returns null when neither strategy resolves; the caller keeps such
  220. // presets visible (can't filter what we can't classify).
  221. //
  222. // ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
  223. // keys are "Bambu Lab <long>", values are short codes.
  224. function extractPresetModel(
  225. name: string,
  226. printerModelsLongToShort: Record<string, string>,
  227. ): string | null {
  228. const atIdx = name.indexOf('@');
  229. if (atIdx >= 0) {
  230. const suffix = name.slice(atIdx + 1).trim();
  231. const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
  232. if (bblMatch) return bblMatch[1].trim();
  233. const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
  234. if (longMatch) {
  235. const longFragment = longMatch[1].trim();
  236. const fullKey = `Bambu Lab ${longFragment}`;
  237. if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
  238. const lower = fullKey.toLowerCase();
  239. for (const [k, v] of Object.entries(printerModelsLongToShort)) {
  240. if (k.toLowerCase() === lower) return v;
  241. }
  242. return longFragment;
  243. }
  244. }
  245. // Body scan — accumulate {token, short} pairs and try long-first.
  246. const tokens: Array<{ token: string; short: string }> = [];
  247. const seen = new Set<string>();
  248. for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
  249. const fragment = longName.replace(/^Bambu Lab\s+/, '');
  250. const key = fragment.toLowerCase();
  251. if (!seen.has(key)) {
  252. tokens.push({ token: fragment, short });
  253. seen.add(key);
  254. }
  255. const shortKey = short.toLowerCase();
  256. if (!seen.has(shortKey)) {
  257. tokens.push({ token: short, short });
  258. seen.add(shortKey);
  259. }
  260. }
  261. tokens.sort((a, b) => b.token.length - a.token.length);
  262. for (const { token, short } of tokens) {
  263. if (_tokenToRegex(token).test(name)) return short;
  264. }
  265. return null;
  266. }
  267. export function ConfigureAmsSlotModal({
  268. isOpen,
  269. onClose,
  270. printerId,
  271. slotInfo,
  272. nozzleDiameter = '0.4',
  273. printerModel,
  274. onSuccess,
  275. fullScreen,
  276. }: ConfigureAmsSlotModalProps) {
  277. const { t } = useTranslation();
  278. const [selectedPresetId, setSelectedPresetId] = useState<string>('');
  279. const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
  280. const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
  281. const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)
  282. const [searchQuery, setSearchQuery] = useState('');
  283. const [showSuccess, setShowSuccess] = useState(false);
  284. const [showExtendedColors, setShowExtendedColors] = useState(false);
  285. const scrolledToRef = useRef<string>('');
  286. // Fetch cloud settings (gracefully handle 401 when logged out)
  287. const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
  288. queryKey: ['cloudSettings'],
  289. queryFn: () => api.getCloudSettings(),
  290. enabled: isOpen,
  291. retry: false,
  292. });
  293. // Orca Cloud filament profiles, same shape as Bambu Cloud's. Each query
  294. // is independent — the picker degrades gracefully if Orca Cloud isn't
  295. // connected (no entries surface, no error banner because we don't want
  296. // to nag users who deliberately only use Bambu Cloud).
  297. const { data: orcaCloudList } = useQuery({
  298. queryKey: ['orcaCloudProfilesForAmsSlot'],
  299. queryFn: () => api.orcaCloudListProfiles(),
  300. enabled: isOpen,
  301. retry: false,
  302. });
  303. // Fetch local presets
  304. const { data: localPresets, isLoading: localLoading } = useQuery({
  305. queryKey: ['localPresets'],
  306. queryFn: () => api.getLocalPresets(),
  307. enabled: isOpen,
  308. });
  309. // Fetch built-in filament names (static fallback)
  310. const { data: builtinFilaments, isLoading: builtinLoading } = useQuery({
  311. queryKey: ['builtinFilaments'],
  312. queryFn: () => api.getBuiltinFilaments(),
  313. enabled: isOpen,
  314. staleTime: Infinity,
  315. });
  316. // Fetch K profiles
  317. const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
  318. queryKey: ['kprofiles', printerId, nozzleDiameter],
  319. queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
  320. enabled: isOpen && !!printerId,
  321. });
  322. // Fetch color catalog
  323. const { data: colorCatalog } = useQuery({
  324. queryKey: ['colorCatalog'],
  325. queryFn: () => api.getColorCatalog(),
  326. enabled: isOpen,
  327. staleTime: Infinity,
  328. });
  329. // Backend Bambu printer-model registry — drives the @BBL short-code matcher
  330. // and (here) the reverse short-code → long-name lookup that lets us check
  331. // imported local presets' `compatible_printers` list against the slot's
  332. // printer (#1623). Long staleTime: the registry only changes across backend
  333. // releases.
  334. const { data: printerModelsData } = useQuery({
  335. queryKey: ['slicerPrinterModels'],
  336. queryFn: api.getSlicerPrinterModels,
  337. enabled: isOpen,
  338. staleTime: Infinity,
  339. });
  340. const compatIndex = useMemo(
  341. () => buildCompatibilityIndex(printerModelsData ?? {}),
  342. [printerModelsData],
  343. );
  344. // The full printer-preset name for this slot's printer — e.g. the short
  345. // code "X1C" resolves to "Bambu Lab X1 Carbon 0.4 nozzle" via the backend
  346. // registry plus the slot's nozzle. Used to filter imported local presets
  347. // whose `compatible_printers` list is keyed by the full slicer preset name.
  348. // Null when the registry hasn't loaded or no model is known — caller skips
  349. // the filter in that case (fail-open, same shape as the cloud-preset filter).
  350. const fullPrinterName = useMemo<string | null>(() => {
  351. if (!printerModel || !printerModelsData) return null;
  352. for (const [longName, shortCode] of Object.entries(printerModelsData)) {
  353. if (shortCode === printerModel) {
  354. return `${longName.startsWith('Bambu Lab ') ? longName : `Bambu Lab ${longName}`} ${nozzleDiameter} nozzle`;
  355. }
  356. }
  357. return null;
  358. }, [printerModel, printerModelsData, nozzleDiameter]);
  359. // Configure slot mutation
  360. const configureMutation = useMutation({
  361. mutationFn: async () => {
  362. if (!selectedPresetId) throw new Error('No filament preset selected');
  363. // Determine preset source. Orca detection is done via setting_id
  364. // lookup as well as the ``orca_`` prefix because the saved-preset
  365. // pre-population on modal open (the useEffect at the top of this
  366. // component) writes ``slotInfo.savedPresetId`` verbatim — which for a
  367. // historical Orca save would be the raw UUID, no prefix. Falling
  368. // through to the cloud lookup in that case would have thrown
  369. // "Selected preset not found".
  370. const isLocal = selectedPresetId.startsWith('local_');
  371. const isBuiltin = selectedPresetId.startsWith('builtin_');
  372. const orcaSettingId = selectedPresetId.startsWith('orca_')
  373. ? selectedPresetId.replace('orca_', '')
  374. : selectedPresetId;
  375. const orcaPresetCandidate = (!isLocal && !isBuiltin)
  376. ? orcaCloudList?.filament.find(p => p.setting_id === orcaSettingId)
  377. : undefined;
  378. const isOrca = !!orcaPresetCandidate;
  379. const orcaPreset = orcaPresetCandidate ?? null;
  380. const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
  381. const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;
  382. const localPreset = isLocal
  383. ? localPresets?.filament.find(p => p.id === localId)
  384. : null;
  385. const builtinPreset = isBuiltin
  386. ? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)
  387. : null;
  388. // Get the selected cloud preset details (null for local/builtin/orca presets)
  389. const selectedPreset = (!isLocal && !isBuiltin && !isOrca)
  390. ? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
  391. : null;
  392. if (!isLocal && !isBuiltin && !isOrca && !selectedPreset) throw new Error('Selected preset not found');
  393. if (isLocal && !localPreset) throw new Error('Selected local preset not found');
  394. if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');
  395. if (isOrca && !orcaPreset) throw new Error('Selected Orca Cloud preset not found');
  396. // Parse the preset name for filament info
  397. const presetName = isLocal
  398. ? localPreset!.name
  399. : isBuiltin
  400. ? builtinPreset!.name
  401. : isOrca
  402. ? orcaPreset!.name
  403. : selectedPreset!.name;
  404. const parsed = parsePresetName(presetName);
  405. // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
  406. const caliIdx = selectedKProfile?.slot_id ?? -1;
  407. // Use custom color if set, otherwise use current slot color or default
  408. const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
  409. // Create the tray_sub_brands from preset name (without printer/nozzle suffix)
  410. const traySubBrands = presetName.replace(/@.+$/, '').trim();
  411. let trayInfoIdx: string;
  412. let settingId: string;
  413. // Parsed material from preset name — handles "Support for" patterns correctly.
  414. // Prefer this over stored filament_type which may have been parsed with old logic.
  415. const parsedMat = parsed.material.toUpperCase();
  416. // Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for
  417. // presets that don't carry a Bambu setting_id of their own (local
  418. // imports and Orca Cloud sync both fall in this bucket). The printer's
  419. // firmware needs SOMETHING in tray_info_idx to recognize the filament
  420. // type for HMS / drying / colour-matching; the closest generic Bambu
  421. // filament for the parsed material is the right choice.
  422. const GENERIC_IDS: Record<string, string> = {
  423. 'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
  424. 'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
  425. 'ABS': 'GFB99', 'ASA': 'GFB98',
  426. 'PC': 'GFC99',
  427. 'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
  428. 'TPU': 'GFU99',
  429. 'PVA': 'GFS99', 'HIPS': 'GFS98',
  430. 'PE': 'GFP99', 'PP': 'GFP97',
  431. };
  432. if (isLocal) {
  433. // Local presets have no Bambu Cloud setting_id, but need a valid
  434. // tray_info_idx for the printer to recognize the filament type.
  435. const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase();
  436. // Try exact match first, then base material (strip suffixes like "-CF", "+", " HF")
  437. trayInfoIdx = GENERIC_IDS[material]
  438. || GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
  439. || GENERIC_IDS[material.replace(/\+$/, '')]
  440. || GENERIC_IDS[material.split(/[-\s]/)[0]]
  441. || '';
  442. settingId = '';
  443. } else if (isOrca) {
  444. // Orca Cloud presets have a UUID setting_id that Bambu printers can't
  445. // resolve; treat them like local imports — derive a generic tray_info
  446. // _idx from the parsed material, leave settingId empty so the slicer
  447. // doesn't get a foreign cloud ID it can't look up.
  448. const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
  449. trayInfoIdx = GENERIC_IDS[material]
  450. || GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
  451. || GENERIC_IDS[material.replace(/\+$/, '')]
  452. || GENERIC_IDS[material.split(/[-\s]/)[0]]
  453. || '';
  454. settingId = '';
  455. } else if (isBuiltin) {
  456. // Built-in presets use the filament_id directly as tray_info_idx
  457. trayInfoIdx = builtinFilamentId!;
  458. settingId = '';
  459. } else {
  460. trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
  461. settingId = selectedPresetId;
  462. // User cloud presets may carry a distinct filament_id in the cloud detail
  463. // (e.g. "P285e239"); prefer it when present. Never fall back to base_id —
  464. // that collapses custom presets to the inherited generic's filament_id and
  465. // makes the slicer resolve the slot to "Generic …" instead (#1053).
  466. if (!selectedPresetId.startsWith('GFS')) {
  467. try {
  468. const detail = await api.getCloudSettingDetail(selectedPresetId);
  469. if (detail.filament_id) {
  470. trayInfoIdx = detail.filament_id;
  471. }
  472. } catch (e) {
  473. console.warn('Failed to fetch preset detail for filament_id:', e);
  474. }
  475. }
  476. }
  477. // Default temp range — use local preset core fields if available
  478. let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
  479. let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
  480. if (!isLocal || isBuiltin || isOrca || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
  481. // Fall back to material-based defaults (prefer parsed material for "Support for" handling)
  482. const material = (isLocal
  483. ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '')
  484. : parsed.material).toUpperCase();
  485. if (material.includes('PLA')) {
  486. tempMin = 190;
  487. tempMax = 230;
  488. } else if (material.includes('PETG')) {
  489. tempMin = 220;
  490. tempMax = 260;
  491. } else if (material.includes('ABS')) {
  492. tempMin = 240;
  493. tempMax = 280;
  494. } else if (material.includes('ASA')) {
  495. tempMin = 240;
  496. tempMax = 280;
  497. } else if (material.includes('TPU')) {
  498. tempMin = 200;
  499. tempMax = 240;
  500. } else if (material === 'PCTG') {
  501. tempMin = 220;
  502. tempMax = 260;
  503. } else if (material.includes('PC')) {
  504. tempMin = 260;
  505. tempMax = 300;
  506. } else if (material.includes('PA') || material.includes('NYLON')) {
  507. tempMin = 250;
  508. tempMax = 290;
  509. }
  510. }
  511. // Parse K value from selected profile
  512. const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
  513. // Determine tray_type: prefer parsed material from preset name (handles "Support for"
  514. // patterns correctly) over stored filament_type which may have been parsed with old logic.
  515. const trayType = isLocal
  516. ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA')
  517. : isOrca
  518. ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || 'PLA')
  519. : (parsed.material || 'PLA');
  520. // Configure the slot via MQTT
  521. const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
  522. tray_info_idx: trayInfoIdx,
  523. tray_type: trayType,
  524. tray_sub_brands: traySubBrands,
  525. tray_color: color + 'FF', // Add alpha
  526. nozzle_temp_min: tempMin,
  527. nozzle_temp_max: tempMax,
  528. cali_idx: caliIdx,
  529. nozzle_diameter: nozzleDiameter,
  530. setting_id: settingId, // Full setting ID for slicer compatibility (empty for local)
  531. // Pass K profile's filament_id and setting_id for proper linking
  532. kprofile_filament_id: selectedKProfile?.filament_id,
  533. kprofile_setting_id: selectedKProfile?.setting_id || undefined,
  534. // Also pass the K value directly for extrusion_cali_set command
  535. k_value: kValue,
  536. });
  537. // Save the preset mapping so we can display the correct name in the UI
  538. // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
  539. // which can't be resolved to a name via the filamentInfo API
  540. const mappingPresetId = isLocal
  541. ? `local_${localId}`
  542. : isBuiltin
  543. ? `builtin_${builtinFilamentId}`
  544. : isOrca
  545. ? selectedPresetId
  546. : selectedPresetId;
  547. const mappingSource = isLocal
  548. ? 'local'
  549. : isBuiltin
  550. ? 'builtin'
  551. : isOrca
  552. ? 'orca_cloud'
  553. : 'cloud';
  554. try {
  555. await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
  556. } catch (e) {
  557. console.warn('Failed to save slot preset mapping:', e);
  558. // Don't fail the whole operation - slot was configured successfully
  559. }
  560. return result;
  561. },
  562. onSuccess: () => {
  563. setShowSuccess(true);
  564. onSuccess?.();
  565. // Close after showing success briefly
  566. setTimeout(() => {
  567. setShowSuccess(false);
  568. onClose();
  569. }, 1500);
  570. },
  571. });
  572. // Reset slot mutation
  573. const resetMutation = useMutation({
  574. mutationFn: async () => {
  575. return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
  576. },
  577. onSuccess: () => {
  578. setShowSuccess(true);
  579. onSuccess?.();
  580. setTimeout(() => {
  581. setShowSuccess(false);
  582. onClose();
  583. }, 1500);
  584. },
  585. });
  586. // Unified preset item for the list (orca_cloud + cloud + local + builtin fallback)
  587. type PresetItem = { id: string; name: string; source: 'orca_cloud' | 'cloud' | 'local' | 'builtin'; isUser: boolean };
  588. // Filter filament presets based on search (merged orca_cloud + cloud + local + builtin)
  589. const filteredPresets = useMemo(() => {
  590. const query = searchQuery.toLowerCase();
  591. const items: PresetItem[] = [];
  592. // Collect IDs already covered by higher-priority tiers to avoid duplicates
  593. const coveredIds = new Set<string>();
  594. // Currently-configured preset should always be shown (bypass model filter)
  595. const savedId = slotInfo.savedPresetId;
  596. const trayIdx = slotInfo.trayInfoIdx;
  597. // 0. Orca Cloud filament presets — surfaced first because the user
  598. // explicitly opted into Orca sync; their picks should outrank Bambu Cloud
  599. // presets of the same name. IDs are prefixed ``orca_`` so the configure
  600. // flow can detect "this is an Orca preset" via a cheap string check
  601. // (mirrors the ``local_`` / ``builtin_`` prefix convention already in use).
  602. if (orcaCloudList?.filament) {
  603. for (const op of orcaCloudList.filament) {
  604. const orcaId = `orca_${op.setting_id}`;
  605. coveredIds.add(op.setting_id);
  606. coveredIds.add(orcaId);
  607. if (query && !op.name.toLowerCase().includes(query)) continue;
  608. if (printerModel) {
  609. const presetModel = extractPresetModel(op.name, printerModelsData ?? {});
  610. if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
  611. }
  612. // All Orca Cloud profiles are user-authored, so isUser is always true.
  613. items.push({ id: orcaId, name: op.name, source: 'orca_cloud', isUser: true });
  614. }
  615. }
  616. // 1. Cloud presets
  617. if (cloudSettings?.filament) {
  618. for (const cp of cloudSettings.filament) {
  619. if (coveredIds.has(cp.setting_id)) continue;
  620. coveredIds.add(cp.setting_id);
  621. // Keep preset if it matches the slot's saved mapping or current tray_info_idx
  622. const isSavedPreset = savedId === cp.setting_id;
  623. const isCurrentPreset = isSavedPreset
  624. || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
  625. // Search filter applies to ALL presets (including saved) — no bypass
  626. if (query && !cp.name.toLowerCase().includes(query)) continue;
  627. // Filter by printer model if set (skip for current preset). Uses the
  628. // alias-aware match so Bambu's "A1 Mini" → "A1M" cloud rename (#1649)
  629. // doesn't hide A1 Mini cloud profiles.
  630. if (!isCurrentPreset && printerModel) {
  631. const presetModel = extractPresetModel(cp.name, printerModelsData ?? {});
  632. if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
  633. }
  634. items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
  635. }
  636. }
  637. // 2. Local presets — filter by the slicer's own ``compatible_printers``
  638. // list when present (#1623). LocalPreset.compatible_printers is a JSON-
  639. // encoded string array; presetCompatibility returns 'mismatch' when the
  640. // list is set and our derived full printer name isn't in it, 'unknown'
  641. // when neither the list nor an @BBL token is parseable — we hide on
  642. // 'mismatch' only so user-imported presets without compatible_printers
  643. // still surface (back-compat with the prior "always show" behaviour for
  644. // hand-edited / lossily-imported presets).
  645. if (localPresets?.filament) {
  646. const savedLocalId = slotInfo.savedPresetId;
  647. for (const lp of localPresets.filament) {
  648. const localId = `local_${lp.id}`;
  649. if (query && !lp.name.toLowerCase().includes(query)) continue;
  650. const isCurrentPreset = savedLocalId === localId;
  651. if (!isCurrentPreset && fullPrinterName) {
  652. let compatList: string[] | null = null;
  653. if (lp.compatible_printers) {
  654. try {
  655. const parsed = JSON.parse(lp.compatible_printers);
  656. if (Array.isArray(parsed)) compatList = parsed.filter((s): s is string => typeof s === 'string');
  657. } catch {
  658. compatList = null;
  659. }
  660. }
  661. const verdict = presetCompatibility(
  662. { name: lp.name, compatible_printers: compatList },
  663. 'filament',
  664. fullPrinterName,
  665. compatIndex,
  666. );
  667. if (verdict === 'mismatch') continue;
  668. }
  669. items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
  670. }
  671. }
  672. // 3. Built-in filament names (fallback — only add entries not already covered)
  673. if (builtinFilaments) {
  674. for (const bf of builtinFilaments) {
  675. if (coveredIds.has(bf.filament_id)) continue;
  676. // Convert filament_id to setting_id format for cloud compatibility
  677. // e.g. "GFA00" → cloud setting_id would be "GFSA00" (insert S after GF)
  678. const settingId = bf.filament_id.startsWith('GF')
  679. ? 'GFS' + bf.filament_id.slice(2)
  680. : bf.filament_id;
  681. if (coveredIds.has(settingId)) continue;
  682. if (!query || bf.name.toLowerCase().includes(query)) {
  683. items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false });
  684. }
  685. }
  686. }
  687. // Sort: local first (user explicitly imported them), then orca_cloud,
  688. // then bambu cloud, then builtin fallback. Matches the SliceModal
  689. // tier priority.
  690. return items.sort((a, b) => {
  691. const sourceOrder = { local: 0, orca_cloud: 1, cloud: 2, builtin: 3 };
  692. if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
  693. if (a.isUser && !b.isUser) return -1;
  694. if (!a.isUser && b.isUser) return 1;
  695. return a.name.localeCompare(b.name);
  696. });
  697. }, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx, fullPrinterName, compatIndex, printerModelsData]);
  698. // Get full preset name for K profile filtering (brand + material, without printer suffix)
  699. const selectedPresetInfo = useMemo(() => {
  700. if (!selectedPresetId) return null;
  701. // Resolve the name from orca, cloud, local, or builtin presets. The
  702. // Orca branch tolerates both ``orca_<UUID>`` and a bare UUID — see the
  703. // configure-mutation comment for why the raw UUID also reaches us.
  704. // ``filamentId`` is the bare Bambu filament_id (e.g. "GFG98") when the
  705. // preset has one — used to id-match K-profiles directly without name
  706. // parsing (#1688). Empty string for paths with no usable filament_id
  707. // (orca presets, local presets), which makes the id-match branch skip.
  708. let presetName: string | null = null;
  709. let filamentId = '';
  710. if (selectedPresetId.startsWith('local_')) {
  711. const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
  712. const lp = localPresets?.filament.find(p => p.id === localId);
  713. presetName = lp?.name || null;
  714. } else if (selectedPresetId.startsWith('builtin_')) {
  715. const builtinFilamentId = selectedPresetId.replace('builtin_', '');
  716. const bf = builtinFilaments?.find(b => b.filament_id === builtinFilamentId);
  717. presetName = bf?.name || null;
  718. filamentId = toFilamentId(builtinFilamentId);
  719. } else {
  720. const orcaCandidateId = selectedPresetId.startsWith('orca_')
  721. ? selectedPresetId.replace('orca_', '')
  722. : selectedPresetId;
  723. const op = orcaCloudList?.filament.find(p => p.setting_id === orcaCandidateId);
  724. if (op) {
  725. presetName = op.name;
  726. } else if (cloudSettings?.filament) {
  727. const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
  728. presetName = cp?.name || null;
  729. if (cp) {
  730. // SlicerSetting only carries setting_id ("GFSG98_09"); toFilamentId
  731. // drops the variant suffix and the "S" infix to yield "GFG98".
  732. filamentId = toFilamentId(cp.setting_id);
  733. }
  734. }
  735. }
  736. if (!presetName) {
  737. return null;
  738. }
  739. // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
  740. let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();
  741. // Strip leading "# " from custom preset names (user convention)
  742. if (nameWithoutSuffix.startsWith('# ')) {
  743. nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
  744. }
  745. const parsed = parsePresetName(nameWithoutSuffix);
  746. return {
  747. fullName: nameWithoutSuffix,
  748. material: parsed.material,
  749. brand: parsed.brand,
  750. filamentId,
  751. };
  752. }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
  753. // For backwards compatibility with the label
  754. const selectedMaterial = selectedPresetInfo?.fullName || '';
  755. // Filter color catalog entries matching the selected preset's brand + material
  756. const catalogColors = useMemo(() => {
  757. if (!colorCatalog || !selectedPresetInfo) return [];
  758. const { fullName, brand } = selectedPresetInfo;
  759. // Try to find colors matching the full preset name (e.g., "PLA Metal")
  760. // The catalog uses the variant as part of the material field (e.g., material="PLA Metal")
  761. // Extract the full material+variant from the preset name
  762. const materialVariant = fullName.replace(/^(Bambu\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\s*/i, '').trim();
  763. return colorCatalog.filter(entry => {
  764. const entryMaterial = (entry.material || '').toUpperCase();
  765. const entryManufacturer = entry.manufacturer.toUpperCase();
  766. // Match material: try full material+variant first, then just material type
  767. const materialMatch = entryMaterial === materialVariant.toUpperCase()
  768. || entryMaterial.includes(materialVariant.toUpperCase())
  769. || materialVariant.toUpperCase().includes(entryMaterial);
  770. if (!materialMatch) return false;
  771. // If brand is present, also match manufacturer
  772. if (brand) {
  773. const upperBrand = brand.toUpperCase();
  774. // Fuzzy match: "Bambu" matches "Bambu Lab", etc.
  775. if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) {
  776. return false;
  777. }
  778. }
  779. return true;
  780. });
  781. }, [colorCatalog, selectedPresetInfo]);
  782. const matchingKProfiles = useMemo(() => {
  783. if (!kprofilesData?.profiles) return [];
  784. if (!selectedPresetInfo) {
  785. // Assigned-but-unconfigured slot (filament loaded but the printer hasn't
  786. // bound a preset yet: tray_type=""/tray_info_idx=""/no slot_preset_mappings
  787. // row). The cali_idx safety net further down lives past the main name+id
  788. // matcher and never runs from here, so surface the slot's currently-active
  789. // K-profile directly so Configure Slot keeps showing it across reopen
  790. // instead of dropping to default 0.020 (#1689 follow-up).
  791. const activeIdx = slotInfo.caliIdx;
  792. if (activeIdx != null && activeIdx > 0) {
  793. const active = kprofilesData.profiles.find(
  794. p => p.slot_id === activeIdx
  795. && (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
  796. );
  797. if (active) return [active];
  798. }
  799. return [];
  800. }
  801. const { fullName, material, brand, filamentId } = selectedPresetInfo;
  802. const upperFullName = fullName.toUpperCase();
  803. const upperMaterial = material.toUpperCase();
  804. // "Generic" leads every built-in Bambu preset name ("Generic PLA",
  805. // "Generic PETG") but is not a manufacturer (#2710). Treating it as one
  806. // put the filter into brand-gated mode and demanded "GENERIC" in the
  807. // K-profile name, which no real profile has — so selecting a built-in
  808. // generic preset matched nothing at all.
  809. const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase();
  810. const presetFid = filamentId; // already normalised via toFilamentId
  811. // Material must be at least 2 chars to avoid false positives
  812. if (!upperMaterial || upperMaterial.length < 2) return [];
  813. // Filter profiles - require brand match if brand is present in selected preset
  814. const filtered = kprofilesData.profiles.filter(p => {
  815. // Preferred: exact filament_id match (#1688). A user's custom K-profile
  816. // whose name doesn't agree with the slicer preset still surfaces when
  817. // both sides agree on filament_id.
  818. //
  819. // Generic GFx99 ids count here too (#2710). The equality test already
  820. // means both sides carry the *same* id, so the old "generic ids
  821. // over-match" exclusion could only ever fire when the selected preset
  822. // was itself the generic one — precisely the case where the match is
  823. // right. The printer keeps one calibration table per filament_id, so a
  824. // slot on "Generic PLA" should offer every profile calibrated under
  825. // Generic PLA, whatever the user named them.
  826. if (presetFid) {
  827. const calFid = toFilamentId(p.filament_id);
  828. if (calFid && calFid === presetFid) {
  829. return true;
  830. }
  831. }
  832. const profileName = p.name.toUpperCase();
  833. // If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
  834. // only show profiles that match the brand
  835. if (upperBrand) {
  836. // Must contain the brand name
  837. if (!profileName.includes(upperBrand)) {
  838. return false;
  839. }
  840. // And must contain the material type
  841. if (!profileName.includes(upperMaterial)) {
  842. return false;
  843. }
  844. return true;
  845. }
  846. // No brand in selected preset - match on full name or material
  847. // Priority 1: Exact match with full name
  848. if (profileName.includes(upperFullName)) {
  849. return true;
  850. }
  851. // Priority 2: Material type match (only when no brand specified)
  852. if (profileName.includes(upperMaterial)) {
  853. return true;
  854. }
  855. // Check for common material aliases
  856. const aliases: Record<string, string[]> = {
  857. 'NYLON': ['PA', 'PA-CF', 'PA6'],
  858. 'PA': ['NYLON'],
  859. };
  860. const materialAliases = aliases[upperMaterial] || [];
  861. for (const alias of materialAliases) {
  862. if (profileName.includes(alias)) {
  863. return true;
  864. }
  865. }
  866. return false;
  867. });
  868. // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
  869. // Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)
  870. const seen = new Map<string, KProfile>();
  871. for (const profile of filtered) {
  872. const key = `${profile.name}|${profile.k_value}`;
  873. const existing = seen.get(key);
  874. if (!existing) {
  875. seen.set(key, profile);
  876. } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
  877. // Replace with profile matching slot's extruder
  878. seen.set(key, profile);
  879. }
  880. }
  881. const result = Array.from(seen.values());
  882. // Always include the slot's currently-active K-profile (by cali_idx / slot_id),
  883. // even if its name and filament_id didn't match the selected preset (#1689).
  884. // A spool assigned under "Generic PLA" can have a K-profile actively bound on
  885. // the printer whose filament_id differs from "Generic PLA"; without this
  886. // safety net the modal shows "not assigned, default 0.020" while the printer
  887. // card's hover-card correctly shows the active profile.
  888. const activeIdx = slotInfo.caliIdx;
  889. if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
  890. const active = kprofilesData.profiles.find(
  891. p => p.slot_id === activeIdx
  892. && (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
  893. );
  894. if (active) result.unshift(active);
  895. }
  896. return result;
  897. }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
  898. // Every remaining K-profile the printer holds, offered under a separate group
  899. // after the matching ones (#2710). The matcher works off preset names and
  900. // filament ids, neither of which the user controls when they name a profile
  901. // after its colour — so there is always a residual chance it filters out a
  902. // profile the user wants. This makes that recoverable in the UI instead of
  903. // sending them to the slicer: the printer's own calibration table is the
  904. // authority on what can be selected, and the backend realigns the slot's
  905. // filament context to whichever profile is picked.
  906. const otherKProfiles = useMemo(() => {
  907. if (!kprofilesData?.profiles) return [];
  908. const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p)));
  909. // Same name+k_value dedup as the matching list, so a multi-nozzle printer's
  910. // duplicate rows don't show up twice here either.
  911. const seen = new Map<string, KProfile>();
  912. for (const profile of kprofilesData.profiles) {
  913. const key = kProfileOptionValue(profile);
  914. if (matched.has(key)) continue;
  915. const existing = seen.get(key);
  916. if (!existing) {
  917. seen.set(key, profile);
  918. } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
  919. seen.set(key, profile);
  920. }
  921. }
  922. return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
  923. }, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
  924. const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
  925. const selectKProfileByValue = useCallback((value: string) => {
  926. if (!value) {
  927. setSelectedKProfile(null);
  928. return;
  929. }
  930. const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value)
  931. || otherKProfiles.find(p => kProfileOptionValue(p) === value)
  932. || null;
  933. setSelectedKProfile(profile);
  934. }, [matchingKProfiles, otherKProfiles]);
  935. // Pre-select current profile when modal opens, reset when closes
  936. useEffect(() => {
  937. if (isOpen) {
  938. // Pre-populate from saved preset mapping (most reliable)
  939. if (slotInfo.savedPresetId) {
  940. setSelectedPresetId(slotInfo.savedPresetId);
  941. } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
  942. // Fallback: try to match by tray_info_idx in cloud presets
  943. // First try exact match on setting_id
  944. let currentPreset = cloudSettings.filament.find(
  945. p => p.setting_id === slotInfo.trayInfoIdx
  946. );
  947. // Then try matching by converting setting_id → filament_id format
  948. if (!currentPreset) {
  949. currentPreset = cloudSettings.filament.find(
  950. p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx
  951. );
  952. }
  953. if (currentPreset) {
  954. setSelectedPresetId(currentPreset.setting_id);
  955. }
  956. } else if (slotInfo.trayInfoIdx && builtinFilaments?.length) {
  957. // Last resort: match trayInfoIdx against builtin presets
  958. const trayIdx = slotInfo.trayInfoIdx;
  959. const match = builtinFilaments.find(bf => bf.filament_id === trayIdx);
  960. if (match) {
  961. setSelectedPresetId(`builtin_${match.filament_id}`);
  962. }
  963. }
  964. // Pre-populate color from current slot (black is valid — empty slots don't pass trayColor)
  965. if (slotInfo.trayColor) {
  966. const hex = slotInfo.trayColor.slice(0, 6);
  967. if (hex) {
  968. setColorHex(hex);
  969. }
  970. }
  971. } else {
  972. // Reset when modal closes
  973. setSelectedPresetId('');
  974. setSelectedKProfile(null);
  975. setColorHex('');
  976. setColorInput('');
  977. setSearchQuery('');
  978. setShowSuccess(false);
  979. scrolledToRef.current = '';
  980. }
  981. }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]);
  982. // Auto-select best matching K profile when preset changes
  983. useEffect(() => {
  984. if (matchingKProfiles.length > 0) {
  985. // Prefer the currently-active K-profile (by cali_idx) if available
  986. if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
  987. const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);
  988. if (active) {
  989. setSelectedKProfile(active);
  990. return;
  991. }
  992. }
  993. // Fallback: first matching profile
  994. setSelectedKProfile(matchingKProfiles[0]);
  995. } else {
  996. setSelectedKProfile(null);
  997. }
  998. }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);
  999. // Escape key handler
  1000. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  1001. if (e.key === 'Escape') {
  1002. onClose();
  1003. }
  1004. }, [onClose]);
  1005. useEffect(() => {
  1006. if (isOpen) {
  1007. document.addEventListener('keydown', handleKeyDown);
  1008. return () => document.removeEventListener('keydown', handleKeyDown);
  1009. }
  1010. }, [isOpen, handleKeyDown]);
  1011. const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading;
  1012. // Scroll selected preset into view when data finishes loading or the selection changes.
  1013. // Uses a ref guard so scrollIntoView only fires once per selection, preventing the
  1014. // infinite scroll loop that occurred on Windows with inline callback refs.
  1015. useEffect(() => {
  1016. if (!isLoading && selectedPresetId && selectedPresetId !== scrolledToRef.current) {
  1017. const raf = requestAnimationFrame(() => {
  1018. const modal = document.querySelector('[class*="fixed inset-0 z-50"]');
  1019. const el = modal?.querySelector(`[data-preset-id="${CSS.escape(selectedPresetId)}"]`);
  1020. if (el) {
  1021. scrolledToRef.current = selectedPresetId;
  1022. el.scrollIntoView({ block: 'nearest' });
  1023. }
  1024. });
  1025. return () => cancelAnimationFrame(raf);
  1026. }
  1027. }, [selectedPresetId, isLoading]);
  1028. if (!isOpen) return null;
  1029. const canSave = selectedPresetId && !configureMutation.isPending;
  1030. // Get display color (custom or slot default)
  1031. const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
  1032. return (
  1033. <div className={`fixed inset-0 z-50 flex ${fullScreen ? '' : 'items-center justify-center'}`}>
  1034. {/* Backdrop */}
  1035. {!fullScreen && (
  1036. <div
  1037. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  1038. onClick={onClose}
  1039. />
  1040. )}
  1041. {/* Modal */}
  1042. <div className={fullScreen
  1043. ? 'relative w-full h-full bg-bambu-dark-secondary flex flex-col'
  1044. : 'relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl'
  1045. }>
  1046. {/* Header */}
  1047. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary shrink-0">
  1048. <div className="flex items-center gap-2">
  1049. <Settings2 className="w-5 h-5 text-bambu-blue" />
  1050. <h2 className="text-lg font-semibold text-white">{t('configureAmsSlot.title')}</h2>
  1051. {/* Inline slot info in fullScreen mode */}
  1052. {fullScreen && (
  1053. <div className="flex items-center gap-2 ml-4 text-sm text-bambu-gray">
  1054. <span className="text-white/30">|</span>
  1055. {slotInfo.trayColor && (
  1056. <span
  1057. className="w-4 h-4 rounded-full border border-black/20"
  1058. style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
  1059. />
  1060. )}
  1061. <span className="text-white/70">
  1062. {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
  1063. </span>
  1064. {slotInfo.traySubBrands && (
  1065. <span>({slotInfo.traySubBrands})</span>
  1066. )}
  1067. </div>
  1068. )}
  1069. </div>
  1070. <button
  1071. onClick={onClose}
  1072. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  1073. >
  1074. <X className="w-5 h-5" />
  1075. </button>
  1076. </div>
  1077. {/* Content */}
  1078. <div className={`p-4 overflow-y-auto ${fullScreen ? 'flex-1 min-h-0' : 'space-y-4 max-h-[60vh]'}`}>
  1079. {/* Success overlay */}
  1080. {showSuccess && (
  1081. <div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
  1082. <div className="text-center space-y-3">
  1083. <CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
  1084. <p className="text-lg font-semibold text-white">{t('configureAmsSlot.slotConfigured')}</p>
  1085. <p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
  1086. </div>
  1087. </div>
  1088. )}
  1089. {/* Slot info */}
  1090. {!fullScreen && (
  1091. <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1092. <p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
  1093. <div className="flex items-center gap-2">
  1094. {slotInfo.trayColor && (
  1095. <span
  1096. className="w-4 h-4 rounded-full border border-black/20"
  1097. style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
  1098. />
  1099. )}
  1100. <span className="text-white font-medium">
  1101. {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
  1102. </span>
  1103. {slotInfo.traySubBrands && (
  1104. <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
  1105. )}
  1106. </div>
  1107. </div>
  1108. )}
  1109. {isLoading ? (
  1110. <div className="flex justify-center py-8">
  1111. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  1112. </div>
  1113. ) : fullScreen ? (
  1114. /* Two-column layout for kiosk display */
  1115. <div className="flex gap-4 h-full">
  1116. {/* Left column: Filament preset list (takes full height) */}
  1117. <div className="w-1/2 flex flex-col min-h-0">
  1118. <label className="block text-sm text-bambu-gray mb-2">
  1119. {t('configureAmsSlot.filamentProfile')} <span className="text-red-600 dark:text-red-400">*</span>
  1120. </label>
  1121. <input
  1122. type="text"
  1123. placeholder={t('configureAmsSlot.searchPresets')}
  1124. value={searchQuery}
  1125. onChange={(e) => setSearchQuery(e.target.value)}
  1126. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2 shrink-0"
  1127. />
  1128. <div className="flex-1 min-h-0 overflow-y-auto space-y-1">
  1129. {filteredPresets.length === 0 ? (
  1130. <p className="text-center py-4 text-bambu-gray">
  1131. {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
  1132. ? t('configureAmsSlot.noPresetsAvailable')
  1133. : t('configureAmsSlot.noMatchingPresets')}
  1134. </p>
  1135. ) : (
  1136. filteredPresets.map((preset) => (
  1137. <button
  1138. key={preset.id}
  1139. data-preset-id={preset.id}
  1140. onClick={() => setSelectedPresetId(preset.id)}
  1141. className={`group w-full p-2 rounded-lg border text-left transition-colors ${
  1142. selectedPresetId === preset.id
  1143. ? 'bg-bambu-green/20 border-bambu-green'
  1144. : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
  1145. }`}
  1146. >
  1147. <div className="flex items-center justify-between">
  1148. <span className="text-white text-sm truncate group-hover:whitespace-normal group-hover:break-all" title={preset.name}>{preset.name}</span>
  1149. <div className="flex items-center gap-1 flex-shrink-0">
  1150. {preset.source === 'local' && (
  1151. <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
  1152. {t('profiles.localProfiles.badge')}
  1153. </span>
  1154. )}
  1155. {preset.source === 'orca_cloud' && (
  1156. <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400">
  1157. {t('configureAmsSlot.orcaCloud')}
  1158. </span>
  1159. )}
  1160. {preset.source === 'cloud' && (
  1161. <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
  1162. {t('configureAmsSlot.bambuCloud')}
  1163. </span>
  1164. )}
  1165. {preset.source === 'builtin' && (
  1166. <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
  1167. {t('configureAmsSlot.builtin')}
  1168. </span>
  1169. )}
  1170. </div>
  1171. </div>
  1172. </button>
  1173. ))
  1174. )}
  1175. </div>
  1176. </div>
  1177. {/* Right column: K Profile + Color */}
  1178. <div className="w-1/2 flex flex-col gap-4 min-h-0 overflow-y-auto">
  1179. {/* K Profile Select */}
  1180. <div>
  1181. <label className="block text-sm text-bambu-gray mb-2">
  1182. {t('configureAmsSlot.kProfileLabel')}
  1183. {selectedMaterial && (
  1184. <span className="ml-2 text-xs text-bambu-blue">
  1185. {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
  1186. </span>
  1187. )}
  1188. </label>
  1189. {hasAnyKProfile ? (
  1190. <div className="relative">
  1191. <select
  1192. value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
  1193. onChange={(e) => selectKProfileByValue(e.target.value)}
  1194. 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 appearance-none pr-10"
  1195. >
  1196. <option value="">{t('configureAmsSlot.noKProfile')}</option>
  1197. {matchingKProfiles.map((profile) => (
  1198. <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
  1199. {profile.name} (K={profile.k_value})
  1200. </option>
  1201. ))}
  1202. {otherKProfiles.length > 0 && (
  1203. <optgroup label={t('configureAmsSlot.otherKProfiles')}>
  1204. {otherKProfiles.map((profile) => (
  1205. <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
  1206. {profile.name} (K={profile.k_value})
  1207. </option>
  1208. ))}
  1209. </optgroup>
  1210. )}
  1211. </select>
  1212. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1213. </div>
  1214. ) : selectedPresetId ? (
  1215. <p className="text-sm text-bambu-gray italic py-2">
  1216. {t('configureAmsSlot.noMatchingKProfiles')}
  1217. </p>
  1218. ) : (
  1219. <span className="inline-block text-xs px-2 py-1 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-500/30">
  1220. {t('configureAmsSlot.selectFilamentFirst')}
  1221. </span>
  1222. )}
  1223. {selectedKProfile && (
  1224. <p className="text-xs text-bambu-green mt-1">
  1225. {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
  1226. </p>
  1227. )}
  1228. </div>
  1229. {/* Custom color */}
  1230. <div>
  1231. <label className="block text-sm text-bambu-gray mb-2">
  1232. {t('configureAmsSlot.customColorLabel')}
  1233. </label>
  1234. {catalogColors.length > 0 && (
  1235. <div className="mb-3">
  1236. <p className="text-xs text-bambu-gray mb-1.5">
  1237. {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
  1238. </p>
  1239. <div className="flex flex-wrap gap-1.5">
  1240. {catalogColors.map((entry) => (
  1241. <button
  1242. key={entry.id}
  1243. onClick={() => {
  1244. const hex = entry.hex_color.replace('#', '').toUpperCase();
  1245. setColorHex(hex);
  1246. setColorInput(entry.color_name);
  1247. }}
  1248. className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
  1249. colorHex === entry.hex_color.replace('#', '').toUpperCase()
  1250. ? 'border-bambu-green scale-105'
  1251. : 'border-white/20 hover:border-white/40'
  1252. }`}
  1253. title={entry.color_name}
  1254. >
  1255. <span
  1256. className="w-4 h-4 rounded-full border border-black/20 flex-shrink-0"
  1257. style={{ backgroundColor: entry.hex_color }}
  1258. />
  1259. <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
  1260. </button>
  1261. ))}
  1262. </div>
  1263. </div>
  1264. )}
  1265. <div className="flex flex-wrap gap-1.5 mb-2">
  1266. {QUICK_COLORS_BASIC.map((color) => (
  1267. <button
  1268. key={color.hex}
  1269. onClick={() => {
  1270. setColorHex(color.hex);
  1271. setColorInput(color.name);
  1272. }}
  1273. className={`w-7 h-7 rounded-md border-2 transition-all ${
  1274. colorHex === color.hex
  1275. ? 'border-bambu-green scale-110'
  1276. : 'border-white/20 hover:border-white/40'
  1277. }`}
  1278. style={{ backgroundColor: `#${color.hex}` }}
  1279. title={color.name}
  1280. />
  1281. ))}
  1282. <button
  1283. onClick={() => setShowExtendedColors(!showExtendedColors)}
  1284. className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
  1285. title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
  1286. >
  1287. {showExtendedColors ? '−' : '+'}
  1288. </button>
  1289. </div>
  1290. {showExtendedColors && (
  1291. <div className="flex flex-wrap gap-1.5 mb-2">
  1292. {QUICK_COLORS_EXTENDED.map((color) => (
  1293. <button
  1294. key={color.hex}
  1295. onClick={() => {
  1296. setColorHex(color.hex);
  1297. setColorInput(color.name);
  1298. }}
  1299. className={`w-7 h-7 rounded-md border-2 transition-all ${
  1300. colorHex === color.hex
  1301. ? 'border-bambu-green scale-110'
  1302. : 'border-white/20 hover:border-white/40'
  1303. }`}
  1304. style={{ backgroundColor: `#${color.hex}` }}
  1305. title={color.name}
  1306. />
  1307. ))}
  1308. </div>
  1309. )}
  1310. <div className="flex gap-2 items-center">
  1311. <div
  1312. className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
  1313. style={{ backgroundColor: `#${displayColor}` }}
  1314. />
  1315. <input
  1316. type="text"
  1317. placeholder={t('configureAmsSlot.colorPlaceholder')}
  1318. value={colorInput}
  1319. onChange={(e) => {
  1320. const input = e.target.value;
  1321. setColorInput(input);
  1322. const nameHex = colorNameToHex(input);
  1323. if (nameHex) {
  1324. setColorHex(nameHex);
  1325. } else {
  1326. const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
  1327. if (cleaned.length === 6) {
  1328. setColorHex(cleaned);
  1329. } else if (cleaned.length === 3) {
  1330. setColorHex(cleaned.split('').map(c => c + c).join(''));
  1331. }
  1332. }
  1333. }}
  1334. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
  1335. />
  1336. {colorHex && (
  1337. <button
  1338. onClick={() => {
  1339. setColorHex('');
  1340. setColorInput('');
  1341. }}
  1342. className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
  1343. title={t('configureAmsSlot.clearCustomColor')}
  1344. >
  1345. {t('configureAmsSlot.clear')}
  1346. </button>
  1347. )}
  1348. </div>
  1349. {colorHex && (
  1350. <p className="text-xs text-bambu-gray mt-1.5">
  1351. {t('configureAmsSlot.hexLabel', { hex: colorHex })}
  1352. </p>
  1353. )}
  1354. </div>
  1355. </div>
  1356. </div>
  1357. ) : (
  1358. <>
  1359. {/* Filament Profile Select */}
  1360. <div>
  1361. <label className="block text-sm text-bambu-gray mb-2">
  1362. {t('configureAmsSlot.filamentProfile')} <span className="text-red-600 dark:text-red-400">*</span>
  1363. </label>
  1364. <div className="relative">
  1365. <input
  1366. type="text"
  1367. placeholder={t('configureAmsSlot.searchPresets')}
  1368. value={searchQuery}
  1369. onChange={(e) => setSearchQuery(e.target.value)}
  1370. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
  1371. />
  1372. <div className="max-h-48 overflow-y-auto space-y-1">
  1373. {filteredPresets.length === 0 ? (
  1374. <p className="text-center py-4 text-bambu-gray">
  1375. {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
  1376. ? t('configureAmsSlot.noPresetsAvailable')
  1377. : t('configureAmsSlot.noMatchingPresets')}
  1378. </p>
  1379. ) : (
  1380. filteredPresets.map((preset) => (
  1381. <button
  1382. key={preset.id}
  1383. data-preset-id={preset.id}
  1384. onClick={() => setSelectedPresetId(preset.id)}
  1385. className={`group w-full p-2 rounded-lg border text-left transition-colors ${
  1386. selectedPresetId === preset.id
  1387. ? 'bg-bambu-green/20 border-bambu-green'
  1388. : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
  1389. }`}
  1390. >
  1391. <div className="flex items-center justify-between">
  1392. <span className="text-white text-sm truncate group-hover:whitespace-normal group-hover:break-all" title={preset.name}>{preset.name}</span>
  1393. <div className="flex items-center gap-1 flex-shrink-0">
  1394. {preset.source === 'local' && (
  1395. <span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
  1396. {t('profiles.localProfiles.badge')}
  1397. </span>
  1398. )}
  1399. {preset.source === 'orca_cloud' && (
  1400. <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400">
  1401. {t('configureAmsSlot.orcaCloud')}
  1402. </span>
  1403. )}
  1404. {preset.source === 'cloud' && (
  1405. <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
  1406. {t('configureAmsSlot.bambuCloud')}
  1407. </span>
  1408. )}
  1409. {preset.source === 'builtin' && (
  1410. <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
  1411. {t('configureAmsSlot.builtin')}
  1412. </span>
  1413. )}
  1414. </div>
  1415. </div>
  1416. </button>
  1417. ))
  1418. )}
  1419. </div>
  1420. </div>
  1421. </div>
  1422. {/* K Profile Select */}
  1423. <div>
  1424. <label className="block text-sm text-bambu-gray mb-2">
  1425. {t('configureAmsSlot.kProfileLabel')}
  1426. {selectedMaterial && (
  1427. <span className="ml-2 text-xs text-bambu-blue">
  1428. {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
  1429. </span>
  1430. )}
  1431. </label>
  1432. {hasAnyKProfile ? (
  1433. <div className="relative">
  1434. <select
  1435. value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
  1436. onChange={(e) => selectKProfileByValue(e.target.value)}
  1437. 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 appearance-none pr-10"
  1438. >
  1439. <option value="">{t('configureAmsSlot.noKProfile')}</option>
  1440. {matchingKProfiles.map((profile) => (
  1441. <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
  1442. {profile.name} (K={profile.k_value})
  1443. </option>
  1444. ))}
  1445. {otherKProfiles.length > 0 && (
  1446. <optgroup label={t('configureAmsSlot.otherKProfiles')}>
  1447. {otherKProfiles.map((profile) => (
  1448. <option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
  1449. {profile.name} (K={profile.k_value})
  1450. </option>
  1451. ))}
  1452. </optgroup>
  1453. )}
  1454. </select>
  1455. <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  1456. </div>
  1457. ) : selectedPresetId ? (
  1458. <p className="text-sm text-bambu-gray italic py-2">
  1459. {t('configureAmsSlot.noMatchingKProfiles')}
  1460. </p>
  1461. ) : (
  1462. <span className="inline-block text-xs px-2 py-1 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-500/30">
  1463. {t('configureAmsSlot.selectFilamentFirst')}
  1464. </span>
  1465. )}
  1466. {selectedKProfile && (
  1467. <p className="text-xs text-bambu-green mt-1">
  1468. {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
  1469. </p>
  1470. )}
  1471. </div>
  1472. {/* Optional: Custom color */}
  1473. <div>
  1474. <label className="block text-sm text-bambu-gray mb-2">
  1475. {t('configureAmsSlot.customColorLabel')}
  1476. </label>
  1477. {/* Catalog colors matching selected preset */}
  1478. {catalogColors.length > 0 && (
  1479. <div className="mb-3">
  1480. <p className="text-xs text-bambu-gray mb-1.5">
  1481. {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
  1482. </p>
  1483. <div className="flex flex-wrap gap-1.5">
  1484. {catalogColors.map((entry) => (
  1485. <button
  1486. key={entry.id}
  1487. onClick={() => {
  1488. const hex = entry.hex_color.replace('#', '').toUpperCase();
  1489. setColorHex(hex);
  1490. setColorInput(entry.color_name);
  1491. }}
  1492. className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
  1493. colorHex === entry.hex_color.replace('#', '').toUpperCase()
  1494. ? 'border-bambu-green scale-105'
  1495. : 'border-white/20 hover:border-white/40'
  1496. }`}
  1497. title={entry.color_name}
  1498. >
  1499. <span
  1500. className="w-4 h-4 rounded-full border border-black/20 flex-shrink-0"
  1501. style={{ backgroundColor: entry.hex_color }}
  1502. />
  1503. <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
  1504. </button>
  1505. ))}
  1506. </div>
  1507. </div>
  1508. )}
  1509. {/* Quick color buttons */}
  1510. <div className="flex flex-wrap gap-1.5 mb-2">
  1511. {QUICK_COLORS_BASIC.map((color) => (
  1512. <button
  1513. key={color.hex}
  1514. onClick={() => {
  1515. setColorHex(color.hex);
  1516. setColorInput(color.name);
  1517. }}
  1518. className={`w-7 h-7 rounded-md border-2 transition-all ${
  1519. colorHex === color.hex
  1520. ? 'border-bambu-green scale-110'
  1521. : 'border-white/20 hover:border-white/40'
  1522. }`}
  1523. style={{ backgroundColor: `#${color.hex}` }}
  1524. title={color.name}
  1525. />
  1526. ))}
  1527. <button
  1528. onClick={() => setShowExtendedColors(!showExtendedColors)}
  1529. className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
  1530. title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
  1531. >
  1532. {showExtendedColors ? '−' : '+'}
  1533. </button>
  1534. </div>
  1535. {/* Extended colors (collapsible) */}
  1536. {showExtendedColors && (
  1537. <div className="flex flex-wrap gap-1.5 mb-2">
  1538. {QUICK_COLORS_EXTENDED.map((color) => (
  1539. <button
  1540. key={color.hex}
  1541. onClick={() => {
  1542. setColorHex(color.hex);
  1543. setColorInput(color.name);
  1544. }}
  1545. className={`w-7 h-7 rounded-md border-2 transition-all ${
  1546. colorHex === color.hex
  1547. ? 'border-bambu-green scale-110'
  1548. : 'border-white/20 hover:border-white/40'
  1549. }`}
  1550. style={{ backgroundColor: `#${color.hex}` }}
  1551. title={color.name}
  1552. />
  1553. ))}
  1554. </div>
  1555. )}
  1556. {/* Color input: name or hex */}
  1557. <div className="flex gap-2 items-center">
  1558. <div
  1559. className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
  1560. style={{ backgroundColor: `#${displayColor}` }}
  1561. />
  1562. <input
  1563. type="text"
  1564. placeholder={t('configureAmsSlot.colorPlaceholder')}
  1565. value={colorInput}
  1566. onChange={(e) => {
  1567. const input = e.target.value;
  1568. setColorInput(input);
  1569. // Try to parse as color name first
  1570. const nameHex = colorNameToHex(input);
  1571. if (nameHex) {
  1572. setColorHex(nameHex);
  1573. } else {
  1574. // Try to parse as hex code
  1575. const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
  1576. if (cleaned.length === 6) {
  1577. setColorHex(cleaned);
  1578. } else if (cleaned.length === 3) {
  1579. // Expand shorthand hex (e.g., F00 -> FF0000)
  1580. setColorHex(cleaned.split('').map(c => c + c).join(''));
  1581. }
  1582. }
  1583. }}
  1584. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
  1585. />
  1586. {colorHex && (
  1587. <button
  1588. onClick={() => {
  1589. setColorHex('');
  1590. setColorInput('');
  1591. }}
  1592. className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
  1593. title={t('configureAmsSlot.clearCustomColor')}
  1594. >
  1595. {t('configureAmsSlot.clear')}
  1596. </button>
  1597. )}
  1598. </div>
  1599. {colorHex && (
  1600. <p className="text-xs text-bambu-gray mt-1.5">
  1601. {t('configureAmsSlot.hexLabel', { hex: colorHex })}
  1602. </p>
  1603. )}
  1604. </div>
  1605. </>
  1606. )}
  1607. </div>
  1608. {/* Footer */}
  1609. <div className="flex justify-between p-4 border-t border-bambu-dark-tertiary shrink-0">
  1610. {/* Reset button on the left */}
  1611. <Button
  1612. variant="secondary"
  1613. onClick={() => resetMutation.mutate()}
  1614. disabled={resetMutation.isPending || configureMutation.isPending}
  1615. className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10"
  1616. >
  1617. {resetMutation.isPending ? (
  1618. <>
  1619. <Loader2 className="w-4 h-4 animate-spin" />
  1620. {t('configureAmsSlot.resetting')}
  1621. </>
  1622. ) : (
  1623. <>
  1624. <RotateCcw className="w-4 h-4" />
  1625. {t('configureAmsSlot.resetSlot')}
  1626. </>
  1627. )}
  1628. </Button>
  1629. {/* Cancel and Configure buttons on the right */}
  1630. <div className="flex gap-2">
  1631. <Button variant="secondary" onClick={onClose}>
  1632. {t('configureAmsSlot.cancel')}
  1633. </Button>
  1634. <Button
  1635. onClick={() => configureMutation.mutate()}
  1636. disabled={!canSave}
  1637. >
  1638. {configureMutation.isPending ? (
  1639. <>
  1640. <Loader2 className="w-4 h-4 animate-spin" />
  1641. {t('configureAmsSlot.configuring')}
  1642. </>
  1643. ) : (
  1644. <>
  1645. <Settings2 className="w-4 h-4" />
  1646. {t('configureAmsSlot.configureSlot')}
  1647. </>
  1648. )}
  1649. </Button>
  1650. </div>
  1651. </div>
  1652. {/* Error */}
  1653. {(configureMutation.isError || resetMutation.isError) && (
  1654. <div className="mx-4 mb-4 p-2 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded text-sm text-red-700 dark:text-red-400">
  1655. {(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
  1656. </div>
  1657. )}
  1658. </div>
  1659. </div>
  1660. );
  1661. }