KProfilesView.tsx 61 KB

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