KProfilesView.tsx 63 KB

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