KProfilesView.tsx 69 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723
  1. import React, { useState, useEffect, useCallback } from 'react';
  2. import { useQuery, useMutation } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Gauge,
  6. Loader2,
  7. RefreshCw,
  8. Printer,
  9. Plus,
  10. X,
  11. AlertCircle,
  12. WifiOff,
  13. Trash2,
  14. Search,
  15. Copy,
  16. Download,
  17. Upload,
  18. CheckSquare,
  19. Square,
  20. StickyNote,
  21. } from 'lucide-react';
  22. import { api } from '../api/client';
  23. import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
  24. import {
  25. buildFilamentPresetOptions,
  26. resolveFilamentId,
  27. type FilamentPresetOption,
  28. type FilamentPresetSource,
  29. } from '../utils/filamentPresets';
  30. import { Card, CardContent } from './Card';
  31. import { Button } from './Button';
  32. import { useToast } from '../contexts/ToastContext';
  33. import { useAuth } from '../contexts/AuthContext';
  34. import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
  35. interface KProfileCardProps {
  36. profile: KProfile;
  37. onEdit: () => void;
  38. onCopy?: () => void;
  39. selectionMode?: boolean;
  40. isSelected?: boolean;
  41. onToggleSelect?: () => void;
  42. note?: string; // Note text to display as preview
  43. }
  44. // Truncate to 3 decimal places (like Bambu Studio) instead of rounding
  45. const truncateK = (value: string) => {
  46. const num = parseFloat(value);
  47. return (Math.trunc(num * 1000) / 1000).toFixed(3);
  48. };
  49. // nozzle_id encodes the flow type, per the slicer's own generator:
  50. // "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
  51. // so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
  52. // not a material code.
  53. const STANDARD_FLOW = 'HS00';
  54. const HIGH_FLOW = 'HH00';
  55. // Many printers omit nozzle_id from their extrusion_cali_get response entirely
  56. // (#1748) — the field simply isn't in the payload. BambuStudio treats that as
  57. // Standard (its parser falls back to nvtStandard when the key is absent), and
  58. // so do we: the flow type stays a real, editable value rather than a blank.
  59. const getNozzleTypePrefix = (nozzleId: string) => {
  60. const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
  61. return match ? match[1] : STANDARD_FLOW;
  62. };
  63. // Short label for the profile list.
  64. const getFlowTypeLabel = (nozzleId: string) =>
  65. getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
  66. // Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
  67. const extractFilamentName = (profileName: string) => {
  68. // Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
  69. // Remove common prefixes - check both underscore and space separators
  70. const prefixes = [
  71. 'High Flow_', 'High Flow ', // underscore or space
  72. 'Standard_', 'Standard ',
  73. 'HF_', 'HF ',
  74. 'S_', 'S ',
  75. ];
  76. for (const prefix of prefixes) {
  77. if (profileName.startsWith(prefix)) {
  78. return profileName.slice(prefix.length);
  79. }
  80. }
  81. // If no prefix found, check for underscore separator
  82. const underscoreIdx = profileName.indexOf('_');
  83. if (underscoreIdx > 0) {
  84. return profileName.slice(underscoreIdx + 1);
  85. }
  86. return profileName;
  87. };
  88. function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {
  89. const flowType = getFlowTypeLabel(profile.nozzle_id);
  90. const diameter = profile.nozzle_diameter;
  91. const handleClick = () => {
  92. if (selectionMode && onToggleSelect) {
  93. onToggleSelect();
  94. } else {
  95. onEdit();
  96. }
  97. };
  98. return (
  99. <div className="flex items-center gap-2">
  100. {selectionMode && (
  101. <button
  102. onClick={onToggleSelect}
  103. className="text-bambu-gray hover:text-white transition-colors p-1"
  104. >
  105. {isSelected ? (
  106. <CheckSquare className="w-4 h-4 text-bambu-green" />
  107. ) : (
  108. <Square className="w-4 h-4" />
  109. )}
  110. </button>
  111. )}
  112. <button
  113. onClick={handleClick}
  114. className={`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${isSelected ? 'ring-1 ring-bambu-green' : ''}`}
  115. >
  116. <div className="flex items-center gap-2">
  117. <span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
  118. {truncateK(profile.k_value)}
  119. </span>
  120. <span className="text-white text-sm truncate flex-1" title={profile.name}>
  121. {profile.name || 'Unnamed'}
  122. </span>
  123. {note && (
  124. <span title="Has note">
  125. <StickyNote className="w-3 h-3 text-yellow-500" />
  126. </span>
  127. )}
  128. <span className="text-xs text-bambu-gray whitespace-nowrap">
  129. {flowType} {diameter}
  130. </span>
  131. </div>
  132. {note && (
  133. <div className="text-xs mt-0.5 truncate text-yellow-500/70" title={note}>
  134. Note: {note.length > 50 ? note.substring(0, 50) + '...' : note}
  135. </div>
  136. )}
  137. </button>
  138. {!selectionMode && onCopy && (
  139. <button
  140. onClick={(e) => {
  141. e.stopPropagation();
  142. onCopy();
  143. }}
  144. className="text-bambu-gray hover:text-white transition-colors p-1"
  145. title="Copy profile"
  146. >
  147. <Copy className="w-4 h-4" />
  148. </button>
  149. )}
  150. </div>
  151. );
  152. }
  153. interface KProfileModalProps {
  154. profile?: KProfile;
  155. printerId: number;
  156. nozzleDiameter: string;
  157. existingProfiles?: KProfile[]; // Existing profiles, used for name resolution
  158. builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
  159. filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
  160. isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
  161. supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
  162. initialNote?: string; // Initial note value for the profile
  163. initialNoteKey?: string | null; // Key the note was stored under (for clearing)
  164. onClose: () => void;
  165. onSave: () => void;
  166. onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
  167. hasPermission: (permission: Permission) => boolean;
  168. }
  169. function KProfileModal({
  170. profile,
  171. printerId,
  172. nozzleDiameter,
  173. existingProfiles = [],
  174. builtinFilaments = [],
  175. filamentPresets = [],
  176. isDualNozzle = false,
  177. supportsFlowType = true,
  178. initialNote = '',
  179. initialNoteKey = null,
  180. onClose,
  181. onSave,
  182. onSaveNote,
  183. hasPermission,
  184. }: KProfileModalProps) {
  185. const { t } = useTranslation();
  186. const { showToast } = useToast();
  187. const [name, setName] = useState(profile?.name || '');
  188. const [kValue, setKValue] = useState(
  189. profile?.k_value ? truncateK(profile.k_value) : '0.020'
  190. );
  191. // What the Filament select is bound to. When editing, the printer's own
  192. // filament_id (the select is read-only). For a new profile, the *preset
  193. // handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
  194. // setting_id or a builtin filament id — which is resolved to a real
  195. // filament_id on submit, since only some tiers carry one directly.
  196. const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
  197. // Split nozzle into type and diameter
  198. // Both selects are read-only while editing: they report what the printer
  199. // holds, they don't set it. '' means the printer reported no nozzle_id, which
  200. // single-nozzle models never do (#1748) — showing "High Flow" there was the
  201. // UI inventing a value the printer never sent.
  202. const [nozzleType, setNozzleType] = useState(
  203. profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
  204. );
  205. const [modalDiameter, setModalDiameter] = useState(
  206. profile?.nozzle_diameter || nozzleDiameter
  207. );
  208. // For new profiles on dual-nozzle: allow selecting multiple extruders
  209. // For editing: use single extruder from the profile
  210. const [selectedExtruders, setSelectedExtruders] = useState<number[]>(
  211. profile ? [profile.extruder_id] : isDualNozzle ? [0, 1] : [0] // Default: both extruders for new dual-nozzle profiles
  212. );
  213. const [isSyncing, setIsSyncing] = useState(false);
  214. const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
  215. const [note, setNote] = useState(initialNote);
  216. const [filamentQuery, setFilamentQuery] = useState('');
  217. // The modal defers its own close so the printer has time to process the
  218. // command; that timer must not outlive the modal.
  219. const { schedule: scheduleClose } = useCancellableTimeout();
  220. // Name for the filament an existing profile is bound to. The builtin table
  221. // (which the parent has already merged with the user's cloud presets) is
  222. // authoritative; a profile whose filament_id is in neither falls back to the
  223. // name the printer stored for it.
  224. const editedFilamentName = React.useMemo(() => {
  225. if (!profile?.filament_id) return '';
  226. const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
  227. if (builtinName) return builtinName;
  228. const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
  229. return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
  230. }, [profile, existingProfiles, builtinFilaments]);
  231. // The tiered list, grouped for rendering. Order is fixed app-wide —
  232. // imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
  233. // and buildFilamentPresetOptions has already sorted by it, so grouping is
  234. // just a partition that preserves that order.
  235. const presetGroups = React.useMemo(() => {
  236. const labels: [FilamentPresetSource, string][] = [
  237. ['local', t('kProfiles.modal.source.local')],
  238. ['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
  239. ['cloud', t('kProfiles.modal.source.bambuCloud')],
  240. ['builtin', t('kProfiles.modal.source.builtin')],
  241. ];
  242. const query = filamentQuery.trim().toLowerCase();
  243. const matches = query
  244. ? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
  245. : filamentPresets;
  246. return labels
  247. .map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
  248. .filter(g => g.items.length > 0);
  249. }, [filamentPresets, filamentQuery, t]);
  250. const saveMutation = useMutation({
  251. mutationFn: (data: KProfileCreate) => {
  252. console.log('[KProfile] Calling API...');
  253. return api.setKProfile(printerId, data);
  254. },
  255. onSuccess: (result, variables) => {
  256. console.log('[KProfile] Save success:', result);
  257. showToast(t('kProfiles.toast.profileSaved'));
  258. // Save note if it changed (including clearing it)
  259. if (onSaveNote && note !== initialNote) {
  260. let profileKey: string;
  261. if (note === '' && initialNoteKey) {
  262. // Clearing note: use the same key it was stored under
  263. profileKey = initialNoteKey;
  264. } else if (profile && profile.slot_id > 0) {
  265. // Editing: use setting_id if available, or composite key with slot_id
  266. profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
  267. } else {
  268. // New profile: use name as key (matched against the reloaded profile,
  269. // so it has to be the resolved filament_id that was sent — not the
  270. // preset handle the user picked).
  271. profileKey = `name_${name}_${variables.filament_id}`;
  272. }
  273. onSaveNote(profileKey, note);
  274. }
  275. // Show syncing indicator while printer processes the command
  276. setIsSyncing(true);
  277. // Add delay before closing to give printer time to process the save
  278. // onSave will trigger refetch in the parent component
  279. scheduleClose(() => {
  280. setIsSyncing(false);
  281. onSave();
  282. }, 2500);
  283. },
  284. onError: (error: Error) => {
  285. console.error('[KProfile] Save error:', error);
  286. showToast(error.message, 'error');
  287. setIsSyncing(false);
  288. },
  289. });
  290. const deleteMutation = useMutation({
  291. mutationFn: (data: KProfileDelete) => {
  292. console.log('[KProfile] Deleting profile...');
  293. return api.deleteKProfile(printerId, data);
  294. },
  295. onSuccess: (result) => {
  296. console.log('[KProfile] Delete success:', result);
  297. showToast(t('kProfiles.toast.profileDeleted'));
  298. // Show syncing indicator while printer processes the command
  299. setIsSyncing(true);
  300. // Add longer delay for delete - printer needs more time to process
  301. // before it can return the updated profile list
  302. scheduleClose(() => {
  303. setIsSyncing(false);
  304. onClose();
  305. }, 4000);
  306. },
  307. onError: (error: Error) => {
  308. console.error('[KProfile] Delete error:', error);
  309. showToast(error.message, 'error');
  310. setIsSyncing(false);
  311. },
  312. });
  313. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  314. const handleDelete = () => {
  315. if (!profile) return;
  316. deleteMutation.mutate({
  317. slot_id: profile.slot_id,
  318. extruder_id: profile.extruder_id,
  319. nozzle_id: profile.nozzle_id,
  320. nozzle_diameter: profile.nozzle_diameter,
  321. filament_id: profile.filament_id,
  322. setting_id: profile.setting_id,
  323. });
  324. };
  325. const handleSubmit = async (e: React.FormEvent) => {
  326. e.preventDefault();
  327. // Validate at least one extruder is selected for dual-nozzle
  328. if (isDualNozzle && !profile && selectedExtruders.length === 0) {
  329. showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
  330. return;
  331. }
  332. // Format k_value to 6 decimal places for Bambu protocol
  333. const formattedKValue = parseFloat(kValue).toFixed(6);
  334. // Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
  335. const nozzleId = `${nozzleType}-${modalDiameter}`;
  336. // An edit is delete + re-add on single-nozzle printers, so the nozzle
  337. // fields have to survive the round trip — both selects are disabled while
  338. // editing. Rebuilding them blindly from the selects is what let a 0.6mm
  339. // profile come back as "HH00-0.4" once the parse defaults had stamped it
  340. // 0.4 (#1748), so prefer whatever the printer reported. Where it reported
  341. // no nozzle_id at all, send the rebuilt one rather than an empty string —
  342. // the field is part of the profile's identity on the wire and the slicer
  343. // always populates it.
  344. const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
  345. const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
  346. // The printer indexes its calibration table by filament_id, so the preset
  347. // the user picked has to be reduced to one before anything is sent. Only
  348. // the builtin tier and Bambu's official cloud presets carry one outright;
  349. // a cloud *user* preset needs its detail fetched, and imported / Orca
  350. // presets have no Bambu id at all and map to the generic for their
  351. // material. Refuse rather than guess when nothing resolves — a profile
  352. // filed under the wrong filament is invisible to the slot that needs it.
  353. let resolvedFilamentId = profile?.filament_id || '';
  354. if (!profile) {
  355. const picked = filamentPresets.find(p => p.id === filamentChoice);
  356. if (!picked) {
  357. showToast(t('kProfiles.toast.selectFilament'), 'error');
  358. return;
  359. }
  360. resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
  361. if (!resolvedFilamentId) {
  362. showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
  363. return;
  364. }
  365. }
  366. // For editing or single extruder: just save one profile
  367. if (profile || selectedExtruders.length === 1) {
  368. const payload = {
  369. name: name,
  370. k_value: formattedKValue,
  371. filament_id: resolvedFilamentId,
  372. nozzle_id: editNozzleId,
  373. nozzle_diameter: editDiameter,
  374. extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
  375. setting_id: profile?.setting_id,
  376. slot_id: profile?.slot_id ?? 0,
  377. };
  378. console.log('[KProfile] Saving profile:', payload);
  379. saveMutation.mutate(payload);
  380. return;
  381. }
  382. // For new profiles with multiple extruders: use batch endpoint
  383. setIsSyncing(true);
  384. setSavingProgress({ current: 1, total: selectedExtruders.length });
  385. // Build payload for all selected extruders
  386. const batchPayload = selectedExtruders.map(extruderId => ({
  387. name: name,
  388. k_value: formattedKValue,
  389. filament_id: resolvedFilamentId,
  390. nozzle_id: nozzleId,
  391. nozzle_diameter: modalDiameter,
  392. extruder_id: extruderId,
  393. setting_id: undefined,
  394. slot_id: 0,
  395. }));
  396. console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
  397. try {
  398. await api.setKProfilesBatch(printerId, batchPayload);
  399. showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
  400. // Save note for new batch profiles
  401. if (onSaveNote && note) {
  402. const profileKey = `name_${name}_${resolvedFilamentId}`;
  403. onSaveNote(profileKey, note);
  404. }
  405. } catch (error) {
  406. console.error('[KProfile] Failed to save batch:', error);
  407. showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
  408. setIsSyncing(false);
  409. setSavingProgress({ current: 0, total: 0 });
  410. return;
  411. }
  412. setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
  413. // Wait for final sync before closing
  414. // onSave will trigger refetch in the parent component
  415. scheduleClose(() => {
  416. setIsSyncing(false);
  417. setSavingProgress({ current: 0, total: 0 });
  418. onSave();
  419. }, 3000);
  420. };
  421. return (
  422. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  423. <Card className="w-full max-w-md relative">
  424. {/* Syncing overlay */}
  425. {isSyncing && (
  426. <div className="absolute inset-0 bg-bambu-dark-secondary/90 flex flex-col items-center justify-center z-10 rounded-lg">
  427. <Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
  428. <p className="text-white font-medium">
  429. {savingProgress.total > 1
  430. ? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
  431. : t('kProfiles.modal.syncing')}
  432. </p>
  433. <p className="text-bambu-gray text-sm mt-1">{t('kProfiles.modal.pleaseWait')}</p>
  434. </div>
  435. )}
  436. <CardContent className="p-0">
  437. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  438. <h2 className="text-xl font-semibold text-white">
  439. {profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
  440. </h2>
  441. <button
  442. onClick={onClose}
  443. className="text-bambu-gray hover:text-white transition-colors"
  444. disabled={isSyncing}
  445. >
  446. <X className="w-5 h-5" />
  447. </button>
  448. </div>
  449. <form onSubmit={handleSubmit} className="p-4 space-y-4">
  450. {/* Profile Name - read-only when editing */}
  451. <div>
  452. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.profileName')}</label>
  453. <input
  454. type="text"
  455. value={name}
  456. onChange={(e) => setName(e.target.value)}
  457. disabled={!!profile}
  458. 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
  459. placeholder={t('kProfiles.modal.profileNamePlaceholder')}
  460. required={!profile}
  461. />
  462. </div>
  463. {/* K-Value - always editable */}
  464. <div>
  465. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.kValue')}</label>
  466. <input
  467. type="text"
  468. inputMode="decimal"
  469. value={kValue}
  470. onChange={(e) => {
  471. // Allow typing any decimal value
  472. const val = e.target.value;
  473. if (val === '' || /^\d*\.?\d*$/.test(val)) {
  474. setKValue(val);
  475. }
  476. }}
  477. onBlur={(e) => {
  478. // Format to 3 decimal places on blur
  479. const num = parseFloat(e.target.value);
  480. if (!isNaN(num)) {
  481. setKValue((Math.trunc(num * 1000) / 1000).toFixed(3));
  482. }
  483. }}
  484. 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 font-mono"
  485. placeholder={t('kProfiles.modal.kValuePlaceholder')}
  486. required
  487. />
  488. <p className="text-xs text-bambu-gray mt-1">
  489. {t('kProfiles.modal.kValueHelp')}
  490. </p>
  491. </div>
  492. {/* Filament - read-only when editing */}
  493. <div>
  494. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
  495. {profile ? (
  496. // Editing or copying: the filament is fixed, so this is a
  497. // readout rather than a control.
  498. <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
  499. {editedFilamentName || profile.filament_id}
  500. </div>
  501. ) : (
  502. // A real list rather than a <select>: Chrome ignores almost
  503. // every CSS property on <optgroup>, so a source heading inside
  504. // a native dropdown can't be made to stand out.
  505. <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
  506. <div className="relative border-b border-bambu-dark-tertiary">
  507. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  508. <input
  509. type="text"
  510. value={filamentQuery}
  511. onChange={(e) => setFilamentQuery(e.target.value)}
  512. placeholder={t('kProfiles.modal.searchFilaments')}
  513. className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
  514. />
  515. </div>
  516. <div className="max-h-56 overflow-y-auto bg-bambu-dark">
  517. {presetGroups.length === 0 ? (
  518. <p className="px-3 py-3 text-xs text-bambu-gray">
  519. {filamentPresets.length === 0
  520. ? t('kProfiles.modal.noFilamentsHelp')
  521. : t('kProfiles.modal.noFilamentMatches')}
  522. </p>
  523. ) : presetGroups.map((group) => (
  524. <div key={group.source}>
  525. <div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
  526. <span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
  527. {group.label}
  528. </span>
  529. <span className="text-[10px] text-bambu-gray">{group.items.length}</span>
  530. </div>
  531. {group.items.map((f) => (
  532. <button
  533. key={f.id}
  534. type="button"
  535. onClick={() => {
  536. setFilamentChoice(f.id);
  537. // Auto-generate the profile name, but never over
  538. // an entry the user typed.
  539. if (!name) {
  540. const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
  541. setName(`${flowLabel} ${f.name}`);
  542. }
  543. }}
  544. className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
  545. filamentChoice === f.id
  546. ? 'bg-bambu-green/20 text-white'
  547. : 'text-white hover:bg-bambu-dark-tertiary'
  548. }`}
  549. >
  550. {f.name}
  551. </button>
  552. ))}
  553. </div>
  554. ))}
  555. </div>
  556. </div>
  557. )}
  558. </div>
  559. {/* Flow Type and Nozzle Size - read-only when editing. Flow type
  560. is hidden on models sold with a single nozzle variant (the
  561. A-series), where the choice would be meaningless — same gate
  562. the slicer applies via support_nozzle_volume(). */}
  563. <div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
  564. <div className={supportsFlowType ? '' : 'hidden'}>
  565. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
  566. <select
  567. value={nozzleType}
  568. onChange={(e) => {
  569. const newNozzleType = e.target.value;
  570. setNozzleType(newNozzleType);
  571. // Update profile name when flow type changes (for new profiles)
  572. // Only auto-generate if name is empty - don't overwrite user input
  573. if (!profile && filamentChoice && !name) {
  574. const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
  575. if (selectedFilament) {
  576. const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
  577. setName(`${flowLabel} ${selectedFilament.name}`);
  578. }
  579. }
  580. }}
  581. disabled={!!profile}
  582. 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
  583. >
  584. <option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
  585. <option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
  586. </select>
  587. </div>
  588. <div>
  589. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.nozzleSize')}</label>
  590. <select
  591. value={modalDiameter}
  592. onChange={(e) => setModalDiameter(e.target.value)}
  593. disabled={!!profile}
  594. 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
  595. >
  596. <option value="0.2">0.2mm</option>
  597. <option value="0.4">0.4mm</option>
  598. <option value="0.6">0.6mm</option>
  599. <option value="0.8">0.8mm</option>
  600. </select>
  601. </div>
  602. </div>
  603. {/* Extruder - only show for dual-nozzle printers */}
  604. {isDualNozzle && (
  605. <div>
  606. <label className="block text-sm text-bambu-gray mb-1">
  607. {profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}
  608. </label>
  609. {profile ? (
  610. // Read-only display for editing
  611. <div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
  612. {profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
  613. </div>
  614. ) : (
  615. // Checkboxes for new profile - can select both
  616. <div className="flex gap-4">
  617. <label className="flex items-center gap-2 cursor-pointer">
  618. <input
  619. type="checkbox"
  620. checked={selectedExtruders.includes(1)}
  621. onChange={(e) => {
  622. if (e.target.checked) {
  623. setSelectedExtruders([...selectedExtruders, 1]);
  624. } else {
  625. setSelectedExtruders(selectedExtruders.filter(id => id !== 1));
  626. }
  627. }}
  628. className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
  629. />
  630. <span className="text-white">{t('kProfiles.modal.left')}</span>
  631. </label>
  632. <label className="flex items-center gap-2 cursor-pointer">
  633. <input
  634. type="checkbox"
  635. checked={selectedExtruders.includes(0)}
  636. onChange={(e) => {
  637. if (e.target.checked) {
  638. setSelectedExtruders([...selectedExtruders, 0]);
  639. } else {
  640. setSelectedExtruders(selectedExtruders.filter(id => id !== 0));
  641. }
  642. }}
  643. className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
  644. />
  645. <span className="text-white">{t('kProfiles.modal.right')}</span>
  646. </label>
  647. </div>
  648. )}
  649. </div>
  650. )}
  651. {/* Notes */}
  652. <div>
  653. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.notes')}</label>
  654. <textarea
  655. value={note}
  656. onChange={(e) => setNote(e.target.value)}
  657. placeholder={t('kProfiles.modal.notesPlaceholder')}
  658. rows={2}
  659. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
  660. />
  661. <p className="text-xs text-bambu-gray mt-1">
  662. {t('kProfiles.modal.notesHelp')}
  663. </p>
  664. </div>
  665. <div className="flex gap-2 pt-4">
  666. {profile && (
  667. <Button
  668. type="button"
  669. variant="secondary"
  670. onClick={() => setShowDeleteConfirm(true)}
  671. disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
  672. title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
  673. className="text-red-500 hover:bg-red-500/10"
  674. >
  675. {deleteMutation.isPending ? (
  676. <Loader2 className="w-4 h-4 animate-spin" />
  677. ) : (
  678. <Trash2 className="w-4 h-4" />
  679. )}
  680. </Button>
  681. )}
  682. <Button
  683. type="button"
  684. variant="secondary"
  685. onClick={onClose}
  686. disabled={isSyncing}
  687. className="flex-1"
  688. >
  689. {t('common.cancel')}
  690. </Button>
  691. <Button
  692. type="submit"
  693. disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
  694. title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}
  695. className="flex-1"
  696. >
  697. {saveMutation.isPending ? (
  698. <Loader2 className="w-4 h-4 animate-spin" />
  699. ) : (
  700. <Gauge className="w-4 h-4" />
  701. )}
  702. {t('common.save')}
  703. </Button>
  704. </div>
  705. </form>
  706. </CardContent>
  707. </Card>
  708. {/* Delete Confirmation Modal */}
  709. {showDeleteConfirm && (
  710. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[60]">
  711. <Card className="w-full max-w-sm">
  712. <CardContent className="p-6">
  713. <div className="flex items-center gap-3 mb-4">
  714. <div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
  715. <Trash2 className="w-5 h-5 text-red-500" />
  716. </div>
  717. <div>
  718. <h3 className="text-lg font-semibold text-white">{t('kProfiles.deleteConfirm.title')}</h3>
  719. <p className="text-sm text-bambu-gray">{t('kProfiles.deleteConfirm.cannotUndo')}</p>
  720. </div>
  721. </div>
  722. <p className="text-bambu-gray mb-6">
  723. {t('kProfiles.deleteConfirm.message', { name: profile?.name })}
  724. </p>
  725. <div className="flex gap-3">
  726. <Button
  727. variant="secondary"
  728. onClick={() => setShowDeleteConfirm(false)}
  729. className="flex-1"
  730. >
  731. {t('common.cancel')}
  732. </Button>
  733. <Button
  734. onClick={() => {
  735. setShowDeleteConfirm(false);
  736. handleDelete();
  737. }}
  738. disabled={deleteMutation.isPending}
  739. className="flex-1 bg-red-500 hover:bg-red-600 text-white"
  740. >
  741. {deleteMutation.isPending ? (
  742. <Loader2 className="w-4 h-4 animate-spin" />
  743. ) : (
  744. <Trash2 className="w-4 h-4" />
  745. )}
  746. {t('common.delete')}
  747. </Button>
  748. </div>
  749. </CardContent>
  750. </Card>
  751. </div>
  752. )}
  753. </div>
  754. );
  755. }
  756. type ExtruderFilter = 'all' | 'left' | 'right';
  757. type FlowTypeFilter = 'all' | 'hf' | 's';
  758. type SortOption = 'name' | 'k_value' | 'filament';
  759. // localStorage keys
  760. const STORAGE_KEYS = {
  761. NOZZLE_DIAMETER: 'bambusy_kprofiles_nozzle',
  762. SORT_OPTION: 'bambusy_kprofiles_sort',
  763. };
  764. export function KProfilesView() {
  765. const { t } = useTranslation();
  766. const { showToast } = useToast();
  767. const { hasPermission } = useAuth();
  768. const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
  769. // Load nozzle diameter from localStorage
  770. const [nozzleDiameter, setNozzleDiameter] = useState(() => {
  771. const saved = localStorage.getItem(STORAGE_KEYS.NOZZLE_DIAMETER);
  772. return saved || '0.4';
  773. });
  774. const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
  775. const [showAddModal, setShowAddModal] = useState(false);
  776. const [copyingProfile, setCopyingProfile] = useState<KProfile | null>(null);
  777. const [searchQuery, setSearchQuery] = useState('');
  778. const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
  779. const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
  780. // Load sort option from localStorage
  781. const [sortOption, setSortOption] = useState<SortOption>(() => {
  782. const saved = localStorage.getItem(STORAGE_KEYS.SORT_OPTION);
  783. return (saved as SortOption) || 'name';
  784. });
  785. // Bulk selection mode
  786. // Use composite key: `${slot_id}_${extruder_id}` since slot_id alone is not unique across extruders
  787. const [selectionMode, setSelectionMode] = useState(false);
  788. const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
  789. const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
  790. const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
  791. // Helper to create unique profile key for selection - wrapped in useCallback to prevent re-renders
  792. const getProfileKey = useCallback((profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`, []);
  793. // Save nozzle diameter to localStorage when it changes
  794. useEffect(() => {
  795. localStorage.setItem(STORAGE_KEYS.NOZZLE_DIAMETER, nozzleDiameter);
  796. }, [nozzleDiameter]);
  797. // Save sort option to localStorage when it changes
  798. useEffect(() => {
  799. localStorage.setItem(STORAGE_KEYS.SORT_OPTION, sortOption);
  800. }, [sortOption]);
  801. // Get available printers
  802. const { data: printers, isLoading: printersLoading } = useQuery({
  803. queryKey: ['printers'],
  804. queryFn: api.getPrinters,
  805. });
  806. // Get K-profiles for selected printer (filtered by nozzle diameter)
  807. const {
  808. data: kprofiles,
  809. isLoading: kprofilesLoading,
  810. isFetching,
  811. error: kprofilesError,
  812. refetch: refetchProfiles,
  813. } = useQuery({
  814. queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
  815. queryFn: async () => {
  816. console.log('[KProfiles] Fetching profiles for printer', selectedPrinter, 'nozzle', nozzleDiameter);
  817. const result = await api.getKProfiles(selectedPrinter!, nozzleDiameter);
  818. console.log('[KProfiles] Received profiles:', result?.profiles?.length || 0, 'profiles');
  819. return result;
  820. },
  821. enabled: !!selectedPrinter,
  822. retry: false,
  823. staleTime: 0, // Always consider data stale to ensure fresh fetch
  824. gcTime: 0, // Don't cache results
  825. refetchOnMount: 'always', // Always refetch when component mounts
  826. });
  827. // A second fetch for 0.4mm profiles used to seed the Add-Profile filament
  828. // dropdown. The dropdown is built from the filament preset tiers now
  829. // (#2719), so the round trip bought nothing — and it fired concurrently
  830. // with the fetch above whenever a different nozzle was selected, which is
  831. // exactly the two-requests-in-flight case that made K-profile fetches time
  832. // out (#1748).
  833. // Fetch builtin filament names for accurate filament_id → name resolution
  834. const { data: builtinFilaments } = useQuery({
  835. queryKey: ['builtinFilaments'],
  836. queryFn: () => api.getBuiltinFilaments(),
  837. staleTime: 300000, // Cache for 5 minutes (static data)
  838. });
  839. // Fetch filament_id → name mapping for user cloud presets (P* IDs)
  840. const { data: filamentIdMap } = useQuery({
  841. queryKey: ['filamentIdMap'],
  842. queryFn: () => api.getFilamentIdMap(),
  843. staleTime: 300000, // Cache for 5 minutes
  844. });
  845. // The other three filament tiers, so a printer with no K-profiles yet can
  846. // still be given its first one (#2719). Each query stands alone and fails
  847. // quietly: not being signed in to a cloud should thin the list, not break
  848. // the page, and the builtin tier above guarantees it is never empty.
  849. const { data: localPresets } = useQuery({
  850. queryKey: ['localPresets'],
  851. queryFn: () => api.getLocalPresets(),
  852. retry: false,
  853. });
  854. const { data: orcaCloudList } = useQuery({
  855. queryKey: ['orcaCloudProfilesForKProfiles'],
  856. queryFn: () => api.orcaCloudListProfiles(),
  857. retry: false,
  858. });
  859. const { data: cloudSettings } = useQuery({
  860. queryKey: ['cloudSettings'],
  861. queryFn: () => api.getCloudSettings(),
  862. retry: false,
  863. });
  864. // Fetch K-profile notes (stored locally)
  865. const {
  866. data: notesData,
  867. refetch: refetchNotes,
  868. } = useQuery({
  869. queryKey: ['kprofile-notes', selectedPrinter],
  870. queryFn: () => api.getKProfileNotes(selectedPrinter!),
  871. enabled: !!selectedPrinter,
  872. staleTime: 30000, // Cache for 30 seconds
  873. });
  874. // Check if error is due to printer not being connected
  875. const isOfflineError = kprofilesError?.message?.includes('not connected');
  876. // Auto-select first connected printer
  877. useEffect(() => {
  878. if (!selectedPrinter && printers && printers.length > 0) {
  879. const activePrinter = printers.find((p) => p.is_active);
  880. if (activePrinter) {
  881. setSelectedPrinter(activePrinter.id);
  882. }
  883. }
  884. }, [selectedPrinter, printers]);
  885. // Refetch profiles when printer selection changes
  886. useEffect(() => {
  887. if (selectedPrinter) {
  888. // Delay refetch to ensure query is enabled after state update
  889. const timer = setTimeout(() => {
  890. refetchProfiles();
  891. }, 150);
  892. return () => clearTimeout(timer);
  893. }
  894. }, [selectedPrinter, nozzleDiameter]); // eslint-disable-line react-hooks/exhaustive-deps
  895. // Get connected printers for display
  896. const connectedPrinters = printers?.filter((p) => p.is_active) || [];
  897. // Build filament lookup for name resolution (builtin + user cloud presets)
  898. const builtinFilamentMap = React.useMemo(() => {
  899. const map = new Map<string, string>();
  900. if (builtinFilaments) {
  901. for (const bf of builtinFilaments) {
  902. map.set(bf.filament_id, bf.name);
  903. }
  904. }
  905. // Also add user cloud presets (P* filament_ids resolved from cloud details)
  906. if (filamentIdMap) {
  907. for (const [fid, name] of Object.entries(filamentIdMap)) {
  908. if (!map.has(fid)) {
  909. map.set(fid, name);
  910. }
  911. }
  912. }
  913. return map;
  914. }, [builtinFilaments, filamentIdMap]);
  915. // Enriched builtin filaments array (builtin + cloud presets merged)
  916. // Pass this to modals so they have the full filament name lookup
  917. const enrichedBuiltinFilaments = React.useMemo(() => {
  918. return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({
  919. filament_id: fid,
  920. name,
  921. }));
  922. }, [builtinFilamentMap]);
  923. // Every filament this install knows about, ranked in the app-wide order:
  924. // imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
  925. // built-in table as the floor.
  926. const filamentPresets = React.useMemo(
  927. () => buildFilamentPresetOptions({
  928. localPresets: localPresets?.filament,
  929. orcaProfiles: orcaCloudList?.filament,
  930. cloudSettings: cloudSettings?.filament,
  931. builtinFilaments,
  932. }),
  933. [localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
  934. );
  935. // Resolve filament name: builtin table first, then extract from profile name
  936. const resolveFilamentName = React.useCallback((profile: KProfile) => {
  937. return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
  938. }, [builtinFilamentMap]);
  939. // Filter and sort profiles
  940. // Note: nozzle diameter filtering is done server-side via MQTT request
  941. const filteredProfiles = React.useMemo(() => {
  942. if (!kprofiles?.profiles) return [];
  943. const filtered = kprofiles.profiles.filter((p) => {
  944. // Search filter - match name or filament_id (case-insensitive)
  945. const query = searchQuery.toLowerCase();
  946. const matchesSearch =
  947. !query ||
  948. p.name.toLowerCase().includes(query) ||
  949. p.filament_id.toLowerCase().includes(query);
  950. // Extruder filter
  951. const matchesExtruder =
  952. extruderFilter === 'all' ||
  953. (extruderFilter === 'left' && p.extruder_id === 1) ||
  954. (extruderFilter === 'right' && p.extruder_id === 0);
  955. // Flow type filter (HH = High Flow, HS = Standard)
  956. const matchesFlowType =
  957. flowTypeFilter === 'all' ||
  958. (flowTypeFilter === 'hf' && p.nozzle_id.startsWith('HH')) ||
  959. (flowTypeFilter === 's' && p.nozzle_id.startsWith('HS'));
  960. return matchesSearch && matchesExtruder && matchesFlowType;
  961. });
  962. // Sort profiles
  963. return filtered.sort((a, b) => {
  964. switch (sortOption) {
  965. case 'k_value':
  966. return parseFloat(a.k_value) - parseFloat(b.k_value);
  967. case 'filament':
  968. return resolveFilamentName(a).localeCompare(resolveFilamentName(b));
  969. case 'name':
  970. default:
  971. return a.name.localeCompare(b.name);
  972. }
  973. });
  974. }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]);
  975. // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
  976. const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
  977. const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
  978. // Whether this printer model is sold with both Standard and High Flow
  979. // nozzles. Comes from the model, not from whether the payload happened to
  980. // carry a nozzle_id — most printers omit that field entirely (#1748) while
  981. // still offering both flows. Only the A-series has a single variant.
  982. const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
  983. // Don't strand the list behind a filter whose control just disappeared.
  984. useEffect(() => {
  985. if (!supportsFlowType) setFlowTypeFilter('all');
  986. }, [supportsFlowType]);
  987. // Keyboard shortcuts
  988. useEffect(() => {
  989. const handleKeyDown = (e: KeyboardEvent) => {
  990. // Don't trigger shortcuts when typing in input fields
  991. if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {
  992. return;
  993. }
  994. // Don't trigger when modal is open
  995. if (editingProfile || showAddModal || copyingProfile) {
  996. return;
  997. }
  998. if (e.key === 'r' || e.key === 'R') {
  999. e.preventDefault();
  1000. refetchProfiles();
  1001. } else if (e.key === 'n' || e.key === 'N') {
  1002. e.preventDefault();
  1003. setShowAddModal(true);
  1004. } else if (e.key === 'Escape' && selectionMode) {
  1005. e.preventDefault();
  1006. setSelectionMode(false);
  1007. setSelectedProfiles(new Set());
  1008. }
  1009. };
  1010. window.addEventListener('keydown', handleKeyDown);
  1011. return () => window.removeEventListener('keydown', handleKeyDown);
  1012. }, [editingProfile, showAddModal, copyingProfile, selectionMode, refetchProfiles]);
  1013. // Export profiles to JSON file
  1014. const handleExport = useCallback(() => {
  1015. if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
  1016. showToast(t('kProfiles.toast.noProfilesToExport'), 'error');
  1017. return;
  1018. }
  1019. const exportData = {
  1020. version: 1,
  1021. exported_at: new Date().toISOString(),
  1022. printer: selectedPrinterData?.name || 'Unknown',
  1023. nozzle_diameter: nozzleDiameter,
  1024. profiles: kprofiles.profiles.map(p => ({
  1025. name: p.name,
  1026. k_value: p.k_value,
  1027. filament_id: p.filament_id,
  1028. nozzle_id: p.nozzle_id,
  1029. nozzle_diameter: p.nozzle_diameter,
  1030. extruder_id: p.extruder_id,
  1031. })),
  1032. };
  1033. const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
  1034. const url = URL.createObjectURL(blob);
  1035. const a = document.createElement('a');
  1036. a.href = url;
  1037. a.download = `kprofiles_${selectedPrinterData?.name || 'printer'}_${nozzleDiameter}mm_${new Date().toISOString().split('T')[0]}.json`;
  1038. document.body.appendChild(a);
  1039. a.click();
  1040. document.body.removeChild(a);
  1041. URL.revokeObjectURL(url);
  1042. showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
  1043. }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
  1044. // Import profiles from JSON file
  1045. const handleImport = useCallback(() => {
  1046. const input = document.createElement('input');
  1047. input.type = 'file';
  1048. input.accept = '.json';
  1049. input.onchange = async (e) => {
  1050. const file = (e.target as HTMLInputElement).files?.[0];
  1051. if (!file) return;
  1052. try {
  1053. const text = await file.text();
  1054. const data = JSON.parse(text);
  1055. if (!data.profiles || !Array.isArray(data.profiles)) {
  1056. showToast(t('kProfiles.toast.invalidFileFormat'), 'error');
  1057. return;
  1058. }
  1059. // Import profiles one by one
  1060. let imported = 0;
  1061. for (const p of data.profiles) {
  1062. if (!p.name || !p.k_value || !p.filament_id) continue;
  1063. try {
  1064. await api.setKProfile(selectedPrinter!, {
  1065. name: p.name,
  1066. k_value: parseFloat(p.k_value).toFixed(6),
  1067. filament_id: p.filament_id,
  1068. // An export from a printer that reports no nozzle_id carries
  1069. // none; fall back to Standard, the same default the slicer's
  1070. // parser uses for a missing field.
  1071. nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
  1072. nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
  1073. extruder_id: p.extruder_id ?? 0,
  1074. slot_id: 0, // Always create new
  1075. });
  1076. imported++;
  1077. // Small delay between imports
  1078. await new Promise(resolve => setTimeout(resolve, 500));
  1079. } catch (err) {
  1080. console.error('Failed to import profile:', p.name, err);
  1081. }
  1082. }
  1083. showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
  1084. refetchProfiles();
  1085. } catch (err) {
  1086. console.error('Import error:', err);
  1087. showToast(t('kProfiles.toast.failedToParseImport'), 'error');
  1088. }
  1089. };
  1090. input.click();
  1091. }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
  1092. // Toggle profile selection using composite key
  1093. const toggleProfileSelection = useCallback((profileKey: string) => {
  1094. setSelectedProfiles(prev => {
  1095. const next = new Set(prev);
  1096. if (next.has(profileKey)) {
  1097. next.delete(profileKey);
  1098. } else {
  1099. next.add(profileKey);
  1100. }
  1101. return next;
  1102. });
  1103. }, []);
  1104. // Select all visible profiles
  1105. const selectAllProfiles = useCallback(() => {
  1106. setSelectedProfiles(new Set(filteredProfiles.map(p => getProfileKey(p))));
  1107. }, [filteredProfiles, getProfileKey]);
  1108. // Delete selected profiles
  1109. const handleBulkDelete = useCallback(() => {
  1110. if (selectedProfiles.size === 0) return;
  1111. setShowBulkDeleteConfirm(true);
  1112. }, [selectedProfiles.size]);
  1113. // Execute the actual bulk delete
  1114. const executeBulkDelete = useCallback(async () => {
  1115. const profilesToDelete = filteredProfiles.filter(p => selectedProfiles.has(getProfileKey(p)));
  1116. setBulkDeleteInProgress(true);
  1117. let deleted = 0;
  1118. for (const profile of profilesToDelete) {
  1119. try {
  1120. await api.deleteKProfile(selectedPrinter!, {
  1121. slot_id: profile.slot_id,
  1122. extruder_id: profile.extruder_id,
  1123. nozzle_id: profile.nozzle_id,
  1124. nozzle_diameter: profile.nozzle_diameter,
  1125. filament_id: profile.filament_id,
  1126. setting_id: profile.setting_id,
  1127. });
  1128. deleted++;
  1129. // Small delay between deletes
  1130. await new Promise(resolve => setTimeout(resolve, 300));
  1131. } catch (err) {
  1132. console.error('Failed to delete profile:', profile.name, err);
  1133. }
  1134. }
  1135. showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
  1136. setBulkDeleteInProgress(false);
  1137. setShowBulkDeleteConfirm(false);
  1138. setSelectionMode(false);
  1139. setSelectedProfiles(new Set());
  1140. refetchProfiles();
  1141. }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
  1142. // Generate possible keys for a profile (for notes lookup)
  1143. // Returns array of keys to check: setting_id, slot-based, name-based
  1144. const getProfileKeys = useCallback((profile: KProfile): string[] => {
  1145. const keys: string[] = [];
  1146. if (profile.setting_id) {
  1147. keys.push(profile.setting_id);
  1148. }
  1149. // Slot-based key (for profiles without setting_id)
  1150. keys.push(`slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`);
  1151. // Name-based key (for newly created profiles)
  1152. keys.push(`name_${profile.name}_${profile.filament_id}`);
  1153. return keys;
  1154. }, []);
  1155. // Save note for a profile
  1156. const handleSaveNote = useCallback(async (profileKey: string, noteText: string) => {
  1157. if (!selectedPrinter) return;
  1158. try {
  1159. await api.setKProfileNote(selectedPrinter, profileKey, noteText);
  1160. refetchNotes();
  1161. } catch (err) {
  1162. console.error('Failed to save note:', err);
  1163. showToast(t('kProfiles.toast.failedToSaveNote'), 'error');
  1164. }
  1165. }, [selectedPrinter, refetchNotes, showToast, t]);
  1166. // Get note for a profile (checks all possible keys)
  1167. // Returns { note, key } so we know which key the note was stored under
  1168. const getNoteWithKey = useCallback((profile: KProfile): { note: string; key: string | null } => {
  1169. if (!notesData?.notes) return { note: '', key: null };
  1170. const keys = getProfileKeys(profile);
  1171. for (const key of keys) {
  1172. if (notesData.notes[key]) {
  1173. return { note: notesData.notes[key], key };
  1174. }
  1175. }
  1176. return { note: '', key: null };
  1177. }, [notesData, getProfileKeys]);
  1178. // Simple getter for display purposes
  1179. const getNote = useCallback((profile: KProfile) => {
  1180. return getNoteWithKey(profile).note;
  1181. }, [getNoteWithKey]);
  1182. if (printersLoading) {
  1183. return (
  1184. <div className="flex justify-center py-12">
  1185. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1186. </div>
  1187. );
  1188. }
  1189. if (!printers || printers.length === 0) {
  1190. return (
  1191. <Card>
  1192. <CardContent className="py-12 text-center">
  1193. <AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
  1194. <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noPrintersConfigured')}</h3>
  1195. <p className="text-bambu-gray">
  1196. {t('kProfiles.addPrinterInSettings')}
  1197. </p>
  1198. </CardContent>
  1199. </Card>
  1200. );
  1201. }
  1202. if (connectedPrinters.length === 0) {
  1203. return (
  1204. <Card>
  1205. <CardContent className="py-12 text-center">
  1206. <Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
  1207. <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noActivePrinters')}</h3>
  1208. <p className="text-bambu-gray">
  1209. {t('kProfiles.enablePrinterConnection')}
  1210. </p>
  1211. </CardContent>
  1212. </Card>
  1213. );
  1214. }
  1215. return (
  1216. <>
  1217. {/* Loading overlay when refetching profiles (not initial load) */}
  1218. {isFetching && !kprofilesLoading && (
  1219. <div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
  1220. <Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
  1221. <p className="text-white font-medium">{t('kProfiles.loadingProfiles')}</p>
  1222. </div>
  1223. )}
  1224. {/* Printer & Nozzle Selector */}
  1225. <div className="flex flex-wrap gap-4 mb-6">
  1226. <div className="flex-1 min-w-48">
  1227. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.printer')}</label>
  1228. <select
  1229. value={selectedPrinter || ''}
  1230. onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
  1231. 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"
  1232. >
  1233. {connectedPrinters.map((printer) => (
  1234. <option key={printer.id} value={printer.id}>
  1235. {printer.name}
  1236. </option>
  1237. ))}
  1238. </select>
  1239. </div>
  1240. <div className="w-32">
  1241. <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.nozzle')}</label>
  1242. <select
  1243. value={nozzleDiameter}
  1244. onChange={(e) => setNozzleDiameter(e.target.value)}
  1245. 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"
  1246. >
  1247. <option value="0.2">0.2mm</option>
  1248. <option value="0.4">0.4mm</option>
  1249. <option value="0.6">0.6mm</option>
  1250. <option value="0.8">0.8mm</option>
  1251. </select>
  1252. </div>
  1253. <div className="flex items-end gap-2">
  1254. <Button
  1255. variant="secondary"
  1256. onClick={() => refetchProfiles()}
  1257. disabled={isFetching || !hasPermission('kprofiles:read')}
  1258. title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}
  1259. >
  1260. <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
  1261. {t('kProfiles.refresh')}
  1262. </Button>
  1263. <Button
  1264. onClick={() => setShowAddModal(true)}
  1265. disabled={!hasPermission('kprofiles:create')}
  1266. title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}
  1267. >
  1268. <Plus className="w-4 h-4" />
  1269. {t('kProfiles.addProfile')}
  1270. </Button>
  1271. </div>
  1272. </div>
  1273. {/* Search & Filter Row */}
  1274. <div className="flex flex-wrap gap-4 mb-4">
  1275. <div className="flex-1 min-w-48 relative">
  1276. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  1277. <input
  1278. type="text"
  1279. value={searchQuery}
  1280. onChange={(e) => setSearchQuery(e.target.value)}
  1281. placeholder={t('kProfiles.searchPlaceholder')}
  1282. className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
  1283. />
  1284. </div>
  1285. {isDualNozzle && (
  1286. <div className="w-36">
  1287. <select
  1288. value={extruderFilter}
  1289. onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
  1290. 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"
  1291. >
  1292. <option value="all">{t('kProfiles.allExtruders')}</option>
  1293. <option value="left">{t('kProfiles.leftOnly')}</option>
  1294. <option value="right">{t('kProfiles.rightOnly')}</option>
  1295. </select>
  1296. </div>
  1297. )}
  1298. {supportsFlowType && (
  1299. <div className="w-32">
  1300. <select
  1301. value={flowTypeFilter}
  1302. onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
  1303. 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"
  1304. >
  1305. <option value="all">{t('kProfiles.allFlow')}</option>
  1306. <option value="hf">{t('kProfiles.hfOnly')}</option>
  1307. <option value="s">{t('kProfiles.sOnly')}</option>
  1308. </select>
  1309. </div>
  1310. )}
  1311. <div className="w-32">
  1312. <select
  1313. value={sortOption}
  1314. onChange={(e) => setSortOption(e.target.value as SortOption)}
  1315. 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"
  1316. >
  1317. <option value="name">{t('kProfiles.sortName')}</option>
  1318. <option value="k_value">{t('kProfiles.sortKValue')}</option>
  1319. <option value="filament">{t('kProfiles.sortFilament')}</option>
  1320. </select>
  1321. </div>
  1322. </div>
  1323. {/* Toolbar Row */}
  1324. <div className="flex flex-wrap gap-2 mb-6">
  1325. <Button
  1326. variant="secondary"
  1327. onClick={handleExport}
  1328. disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
  1329. title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}
  1330. >
  1331. <Download className="w-4 h-4" />
  1332. {t('kProfiles.export')}
  1333. </Button>
  1334. <Button
  1335. variant="secondary"
  1336. onClick={handleImport}
  1337. disabled={!hasPermission('kprofiles:create')}
  1338. title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}
  1339. >
  1340. <Upload className="w-4 h-4" />
  1341. {t('kProfiles.import')}
  1342. </Button>
  1343. <div className="flex-1" />
  1344. {selectionMode ? (
  1345. <>
  1346. <Button
  1347. variant="secondary"
  1348. onClick={selectAllProfiles}
  1349. >
  1350. <CheckSquare className="w-4 h-4" />
  1351. {t('kProfiles.selectAll')}
  1352. </Button>
  1353. <Button
  1354. variant="secondary"
  1355. onClick={handleBulkDelete}
  1356. disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
  1357. className="text-red-500 hover:bg-red-500/10"
  1358. title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
  1359. >
  1360. <Trash2 className="w-4 h-4" />
  1361. {t('kProfiles.delete')} ({selectedProfiles.size})
  1362. </Button>
  1363. <Button
  1364. variant="secondary"
  1365. onClick={() => {
  1366. setSelectionMode(false);
  1367. setSelectedProfiles(new Set());
  1368. }}
  1369. >
  1370. <X className="w-4 h-4" />
  1371. {t('common.cancel')}
  1372. </Button>
  1373. </>
  1374. ) : (
  1375. <Button
  1376. variant="secondary"
  1377. onClick={() => setSelectionMode(true)}
  1378. disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
  1379. title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
  1380. >
  1381. <CheckSquare className="w-4 h-4" />
  1382. {t('kProfiles.select')}
  1383. </Button>
  1384. )}
  1385. </div>
  1386. {/* K-Profiles Grid */}
  1387. {kprofilesLoading ? (
  1388. <div className="flex justify-center py-12">
  1389. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  1390. </div>
  1391. ) : isOfflineError ? (
  1392. <Card>
  1393. <CardContent className="py-12 text-center">
  1394. <WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
  1395. <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.printerOffline')}</h3>
  1396. <p className="text-bambu-gray mb-4">
  1397. {t('kProfiles.printerOfflineDesc')}
  1398. </p>
  1399. <Button variant="secondary" onClick={() => refetchProfiles()}>
  1400. <RefreshCw className="w-4 h-4" />
  1401. {t('common.refresh')}
  1402. </Button>
  1403. </CardContent>
  1404. </Card>
  1405. ) : filteredProfiles.length > 0 ? (
  1406. isDualNozzle ? (
  1407. // Dual-nozzle: show Left/Right columns
  1408. <div className="grid grid-cols-2 gap-4">
  1409. {/* Left Extruder (extruder_id 1 on Bambu) */}
  1410. <div>
  1411. <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.leftExtruder')}</h3>
  1412. <div className="space-y-1">
  1413. {filteredProfiles
  1414. .filter((p) => p.extruder_id === 1)
  1415. .map((profile) => (
  1416. <KProfileCard
  1417. key={getProfileKey(profile)}
  1418. profile={profile}
  1419. onEdit={() => setEditingProfile(profile)}
  1420. onCopy={() => setCopyingProfile(profile)}
  1421. selectionMode={selectionMode}
  1422. isSelected={selectedProfiles.has(getProfileKey(profile))}
  1423. onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
  1424. note={getNote(profile)}
  1425. />
  1426. ))}
  1427. </div>
  1428. </div>
  1429. {/* Right Extruder (extruder_id 0 on Bambu) */}
  1430. <div>
  1431. <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.rightExtruder')}</h3>
  1432. <div className="space-y-1">
  1433. {filteredProfiles
  1434. .filter((p) => p.extruder_id === 0)
  1435. .map((profile) => (
  1436. <KProfileCard
  1437. key={getProfileKey(profile)}
  1438. profile={profile}
  1439. onEdit={() => setEditingProfile(profile)}
  1440. onCopy={() => setCopyingProfile(profile)}
  1441. selectionMode={selectionMode}
  1442. isSelected={selectedProfiles.has(getProfileKey(profile))}
  1443. onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
  1444. note={getNote(profile)}
  1445. />
  1446. ))}
  1447. </div>
  1448. </div>
  1449. </div>
  1450. ) : (
  1451. // Single-nozzle: show all profiles in one list
  1452. <div className="space-y-1">
  1453. {filteredProfiles.map((profile) => (
  1454. <KProfileCard
  1455. key={getProfileKey(profile)}
  1456. profile={profile}
  1457. onEdit={() => setEditingProfile(profile)}
  1458. onCopy={() => setCopyingProfile(profile)}
  1459. selectionMode={selectionMode}
  1460. isSelected={selectedProfiles.has(getProfileKey(profile))}
  1461. onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
  1462. note={getNote(profile)}
  1463. />
  1464. ))}
  1465. </div>
  1466. )
  1467. ) : searchQuery || extruderFilter !== 'all' || flowTypeFilter !== 'all' ? (
  1468. <Card>
  1469. <CardContent className="py-12 text-center">
  1470. <Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
  1471. <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noMatchingProfiles')}</h3>
  1472. <p className="text-bambu-gray">
  1473. {t('kProfiles.noMatchingProfilesDesc')}
  1474. </p>
  1475. </CardContent>
  1476. </Card>
  1477. ) : (
  1478. <Card>
  1479. <CardContent className="py-12 text-center">
  1480. <Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
  1481. <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noKProfiles')}</h3>
  1482. <p className="text-bambu-gray mb-4">
  1483. {t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
  1484. </p>
  1485. <Button onClick={() => setShowAddModal(true)}>
  1486. <Plus className="w-4 h-4" />
  1487. {t('kProfiles.createFirstProfile')}
  1488. </Button>
  1489. </CardContent>
  1490. </Card>
  1491. )}
  1492. {/* Edit Modal */}
  1493. {editingProfile && selectedPrinter && (() => {
  1494. const { note, key } = getNoteWithKey(editingProfile);
  1495. return (
  1496. <KProfileModal
  1497. profile={editingProfile}
  1498. printerId={selectedPrinter}
  1499. nozzleDiameter={nozzleDiameter}
  1500. existingProfiles={kprofiles?.profiles}
  1501. builtinFilaments={enrichedBuiltinFilaments}
  1502. filamentPresets={filamentPresets}
  1503. isDualNozzle={isDualNozzle}
  1504. supportsFlowType={supportsFlowType}
  1505. initialNote={note}
  1506. initialNoteKey={key}
  1507. onSaveNote={handleSaveNote}
  1508. hasPermission={hasPermission}
  1509. onClose={() => {
  1510. console.log('[KProfiles] Edit modal onClose - refetching profiles...');
  1511. setEditingProfile(null);
  1512. refetchProfiles(); // Refetch after close (handles delete case)
  1513. }}
  1514. onSave={() => {
  1515. setEditingProfile(null);
  1516. refetchProfiles();
  1517. }}
  1518. />
  1519. );
  1520. })()}
  1521. {/* Add Modal */}
  1522. {showAddModal && selectedPrinter && (
  1523. <KProfileModal
  1524. printerId={selectedPrinter}
  1525. nozzleDiameter={nozzleDiameter}
  1526. existingProfiles={kprofiles?.profiles}
  1527. builtinFilaments={enrichedBuiltinFilaments}
  1528. filamentPresets={filamentPresets}
  1529. isDualNozzle={isDualNozzle}
  1530. supportsFlowType={supportsFlowType}
  1531. onSaveNote={handleSaveNote}
  1532. hasPermission={hasPermission}
  1533. onClose={() => {
  1534. setShowAddModal(false);
  1535. refetchProfiles(); // Refetch after close
  1536. }}
  1537. onSave={() => {
  1538. setShowAddModal(false);
  1539. refetchProfiles();
  1540. }}
  1541. />
  1542. )}
  1543. {/* Copy Modal - opens add modal with prefilled values from source profile */}
  1544. {copyingProfile && selectedPrinter && (
  1545. <KProfileModal
  1546. printerId={selectedPrinter}
  1547. nozzleDiameter={nozzleDiameter}
  1548. existingProfiles={kprofiles?.profiles}
  1549. builtinFilaments={enrichedBuiltinFilaments}
  1550. filamentPresets={filamentPresets}
  1551. isDualNozzle={isDualNozzle}
  1552. supportsFlowType={supportsFlowType}
  1553. onSaveNote={handleSaveNote}
  1554. hasPermission={hasPermission}
  1555. // Pass profile data but without slot_id to create a new profile
  1556. profile={{
  1557. ...copyingProfile,
  1558. slot_id: 0, // Force new profile creation
  1559. name: `${copyingProfile.name} (Copy)`, // Indicate it's a copy
  1560. }}
  1561. onClose={() => {
  1562. setCopyingProfile(null);
  1563. refetchProfiles();
  1564. }}
  1565. onSave={() => {
  1566. setCopyingProfile(null);
  1567. refetchProfiles();
  1568. }}
  1569. />
  1570. )}
  1571. {/* Bulk Delete Confirmation Modal */}
  1572. {showBulkDeleteConfirm && (
  1573. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  1574. <Card className="w-full max-w-sm">
  1575. <CardContent className="p-6">
  1576. <div className="flex items-center gap-3 mb-4">
  1577. <div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
  1578. <Trash2 className="w-5 h-5 text-red-500" />
  1579. </div>
  1580. <div>
  1581. <h3 className="text-lg font-semibold text-white">{t('kProfiles.bulkDelete.title')}</h3>
  1582. <p className="text-sm text-bambu-gray">{t('kProfiles.bulkDelete.cannotUndo')}</p>
  1583. </div>
  1584. </div>
  1585. <p className="text-bambu-gray mb-6">
  1586. {t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
  1587. </p>
  1588. <div className="flex gap-3">
  1589. <Button
  1590. variant="secondary"
  1591. onClick={() => setShowBulkDeleteConfirm(false)}
  1592. disabled={bulkDeleteInProgress}
  1593. className="flex-1"
  1594. >
  1595. {t('common.cancel')}
  1596. </Button>
  1597. <Button
  1598. onClick={executeBulkDelete}
  1599. disabled={bulkDeleteInProgress}
  1600. className="flex-1 bg-red-500 hover:bg-red-600 text-white"
  1601. >
  1602. {bulkDeleteInProgress ? (
  1603. <Loader2 className="w-4 h-4 animate-spin" />
  1604. ) : (
  1605. <Trash2 className="w-4 h-4" />
  1606. )}
  1607. {t('common.delete')}
  1608. </Button>
  1609. </div>
  1610. </CardContent>
  1611. </Card>
  1612. </div>
  1613. )}
  1614. </>
  1615. );
  1616. }