KProfilesView.tsx 70 KB

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