LocalProfilesView.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import { useState, useMemo, useCallback } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import {
  5. Upload,
  6. Loader2,
  7. Search,
  8. Trash2,
  9. ChevronDown,
  10. ChevronUp,
  11. HardDrive,
  12. Droplet,
  13. Settings2,
  14. Layers,
  15. AlertCircle,
  16. } from 'lucide-react';
  17. import { api } from '../api/client';
  18. import type { LocalPreset, LocalPresetsResponse } from '../api/client';
  19. import { Card, CardContent } from './Card';
  20. import { Button } from './Button';
  21. import { useToast } from '../contexts/ToastContext';
  22. import { useAuth } from '../contexts/AuthContext';
  23. // Known material types for name-parsing fallback
  24. const MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'PVA', 'HIPS', 'PP', 'PET', 'NYLON'];
  25. const FILAMENT_TYPE_COLORS: Record<string, string> = {
  26. PLA: 'E8E8E8', PETG: '4A90D9', ABS: 'E67E22', ASA: 'D35400',
  27. TPU: '9B59B6', PC: 'BDC3C7', PA: '2ECC71', NYLON: '2ECC71',
  28. PVA: 'F1C40F', HIPS: '95A5A6', PP: 'ECF0F1', PET: '3498DB',
  29. };
  30. // Extract material type from preset name as fallback
  31. function parseMaterialFromName(name: string): string | null {
  32. const upper = name.toUpperCase();
  33. for (const mat of MATERIAL_TYPES) {
  34. if (new RegExp(`\\b${mat}\\b`).test(upper)) return mat;
  35. }
  36. return null;
  37. }
  38. // Extract vendor from preset name (text before the material type)
  39. function parseVendorFromName(name: string): string | null {
  40. // Strip printer/nozzle suffix first (e.g. "@BBL X1C")
  41. const clean = name.replace(/@.+$/, '').trim();
  42. const upper = clean.toUpperCase();
  43. for (const mat of MATERIAL_TYPES) {
  44. const idx = upper.indexOf(mat);
  45. if (idx > 0) {
  46. const vendor = clean.slice(0, idx).trim();
  47. // Skip if vendor looks like a generic prefix (e.g., "Generic", "Bambu")
  48. if (vendor && vendor.length > 1) return vendor;
  49. }
  50. }
  51. return null;
  52. }
  53. function PresetCard({
  54. preset,
  55. onDelete,
  56. onExpand,
  57. isExpanded,
  58. }: {
  59. preset: LocalPreset;
  60. onDelete: (id: number) => void;
  61. onExpand: (id: number | null) => void;
  62. isExpanded: boolean;
  63. }) {
  64. const { t } = useTranslation();
  65. const { hasPermission } = useAuth();
  66. // Resolve material type: DB field → parse from name
  67. const material = preset.filament_type || parseMaterialFromName(preset.name);
  68. // Resolve vendor: DB field → parse from name
  69. const vendor = preset.filament_vendor || parseVendorFromName(preset.name);
  70. // Parse colour for swatch — try explicit colour, then fall back to material type
  71. let colourHex: string | null = null;
  72. let hasExplicitColour = false;
  73. if (preset.default_filament_colour) {
  74. try {
  75. const parsed = JSON.parse(preset.default_filament_colour);
  76. const raw = Array.isArray(parsed) ? parsed[0] : parsed;
  77. if (typeof raw === 'string' && /^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {
  78. colourHex = raw.replace('#', '').slice(0, 6);
  79. hasExplicitColour = true;
  80. }
  81. } catch {
  82. const raw = preset.default_filament_colour;
  83. if (/^#?[0-9a-fA-F]{6,8}$/.test(raw.replace('#', ''))) {
  84. colourHex = raw.replace('#', '').slice(0, 6);
  85. hasExplicitColour = true;
  86. }
  87. }
  88. }
  89. if (!colourHex && material) {
  90. colourHex = FILAMENT_TYPE_COLORS[material.toUpperCase()] || null;
  91. }
  92. return (
  93. <Card className="bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-dark-tertiary/80 transition-colors">
  94. <CardContent className="p-3">
  95. <div className="flex items-start justify-between gap-2">
  96. <div className="flex-1 min-w-0">
  97. <div className="flex items-center gap-2 mb-1">
  98. {/* 1) Color dot — always shown for filament presets, dimmed if no explicit colour */}
  99. {preset.preset_type === 'filament' && (
  100. <div
  101. className={`w-4 h-4 rounded-full border border-black/20 flex-shrink-0 ${
  102. !hasExplicitColour && !colourHex ? 'opacity-25' : !hasExplicitColour ? 'opacity-50' : ''
  103. }`}
  104. style={{ backgroundColor: colourHex ? `#${colourHex}` : '#666' }}
  105. />
  106. )}
  107. <span className="text-sm font-medium text-white truncate">{preset.name}</span>
  108. </div>
  109. <div className="flex items-center gap-2 flex-wrap">
  110. {/* 2) Material tag — fallback to name parsing */}
  111. {material && (
  112. <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
  113. {material}
  114. </span>
  115. )}
  116. {/* 3) Vendor — fallback to name parsing */}
  117. {vendor && (
  118. <span className="text-xs text-bambu-gray">{vendor}</span>
  119. )}
  120. <span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
  121. {t('profiles.localProfiles.badge')}
  122. </span>
  123. </div>
  124. </div>
  125. <div className="flex items-center gap-1 flex-shrink-0">
  126. {/* 4) Only delete, no edit */}
  127. {hasPermission('settings:update') && (
  128. <button
  129. onClick={() => onDelete(preset.id)}
  130. className="p-1 text-bambu-gray hover:text-red-600 dark:hover:text-red-400 transition-colors"
  131. title={t('profiles.localProfiles.delete')}
  132. >
  133. <Trash2 className="w-3.5 h-3.5" />
  134. </button>
  135. )}
  136. <button
  137. onClick={() => onExpand(isExpanded ? null : preset.id)}
  138. className="p-1 text-bambu-gray hover:text-white transition-colors"
  139. >
  140. {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
  141. </button>
  142. </div>
  143. </div>
  144. {/* 5) Expanded detail — show meaningful fields, hide self-inherits */}
  145. {isExpanded && (
  146. <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary text-xs space-y-1.5">
  147. {material && (
  148. <div className="flex justify-between">
  149. <span className="text-bambu-gray">{t('profiles.localProfiles.filamentType')}</span>
  150. <span className="text-white">{material}</span>
  151. </div>
  152. )}
  153. {vendor && (
  154. <div className="flex justify-between">
  155. <span className="text-bambu-gray">{t('profiles.localProfiles.vendor')}</span>
  156. <span className="text-white">{vendor}</span>
  157. </div>
  158. )}
  159. {preset.nozzle_temp_min != null && preset.nozzle_temp_max != null && (
  160. <div className="flex justify-between">
  161. <span className="text-bambu-gray">{t('profiles.localProfiles.nozzleTemp')}</span>
  162. <span className="text-white">{preset.nozzle_temp_min}–{preset.nozzle_temp_max}°C</span>
  163. </div>
  164. )}
  165. {preset.filament_cost && (
  166. <div className="flex justify-between">
  167. <span className="text-bambu-gray">{t('profiles.localProfiles.cost')}</span>
  168. <span className="text-white">{preset.filament_cost}</span>
  169. </div>
  170. )}
  171. {preset.filament_density && (
  172. <div className="flex justify-between">
  173. <span className="text-bambu-gray">{t('profiles.localProfiles.density')}</span>
  174. <span className="text-white">{preset.filament_density} g/cm³</span>
  175. </div>
  176. )}
  177. {preset.pressure_advance && (
  178. <div className="flex justify-between">
  179. <span className="text-bambu-gray">{t('profiles.localProfiles.pressureAdvance')}</span>
  180. <span className="text-white">{preset.pressure_advance}</span>
  181. </div>
  182. )}
  183. {preset.compatible_printers && (
  184. <div className="flex justify-between">
  185. <span className="text-bambu-gray">{t('profiles.localProfiles.compatiblePrinters')}</span>
  186. <span className="text-white truncate ml-2">
  187. {(() => { try { return JSON.parse(preset.compatible_printers).join(', '); } catch { return preset.compatible_printers; } })()}
  188. </span>
  189. </div>
  190. )}
  191. {/* Only show inherits if different from own name */}
  192. {preset.inherits && preset.inherits !== preset.name && (
  193. <div className="flex justify-between">
  194. <span className="text-bambu-gray">{t('profiles.localProfiles.inheritsFrom')}</span>
  195. <span className="text-white truncate ml-2">{preset.inherits}</span>
  196. </div>
  197. )}
  198. <div className="flex justify-between">
  199. <span className="text-bambu-gray">{t('profiles.localProfiles.source')}</span>
  200. <span className="text-white capitalize">{preset.source}</span>
  201. </div>
  202. </div>
  203. )}
  204. </CardContent>
  205. </Card>
  206. );
  207. }
  208. export function LocalProfilesView() {
  209. const { t } = useTranslation();
  210. const { hasPermission } = useAuth();
  211. const queryClient = useQueryClient();
  212. const { showToast } = useToast();
  213. const [searchQuery, setSearchQuery] = useState('');
  214. const [expandedId, setExpandedId] = useState<number | null>(null);
  215. const [isDragging, setIsDragging] = useState(false);
  216. const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
  217. const { data: presets, isLoading } = useQuery({
  218. queryKey: ['localPresets'],
  219. queryFn: () => api.getLocalPresets(),
  220. });
  221. const importMutation = useMutation({
  222. mutationFn: async (files: FileList) => {
  223. const results = [];
  224. for (const file of Array.from(files)) {
  225. const formData = new FormData();
  226. formData.append('file', file);
  227. results.push(await api.importLocalPresets(formData));
  228. }
  229. return results;
  230. },
  231. onSuccess: (results) => {
  232. queryClient.invalidateQueries({ queryKey: ['localPresets'] });
  233. // The SliceModal reads from a separate `slicerPresets` query that lists
  234. // cloud + local + standard in one shot. Without this second invalidation
  235. // freshly-imported profiles wouldn't appear in the SliceModal dropdown
  236. // until that query's staleTime elapsed plus a refocus / remount (#1581).
  237. queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
  238. let totalImported = 0;
  239. let totalSkipped = 0;
  240. let totalErrors = 0;
  241. for (const r of results) {
  242. totalImported += r.imported;
  243. totalSkipped += r.skipped;
  244. totalErrors += r.errors.length;
  245. }
  246. if (totalImported > 0) {
  247. showToast(t('profiles.localProfiles.toast.importSuccess', { count: totalImported }));
  248. }
  249. if (totalSkipped > 0) {
  250. showToast(t('profiles.localProfiles.toast.importSkipped', { count: totalSkipped }), 'warning');
  251. }
  252. if (totalErrors > 0) {
  253. showToast(t('profiles.localProfiles.toast.importError', { count: totalErrors }), 'error');
  254. }
  255. },
  256. onError: (err: Error) => {
  257. showToast(err.message, 'error');
  258. },
  259. });
  260. const deleteMutation = useMutation({
  261. mutationFn: (id: number) => api.deleteLocalPreset(id),
  262. onSuccess: (_, id) => {
  263. // Optimistically drop the row from the cached list so the rendered table
  264. // updates the instant the DELETE returns. Without this the row stays
  265. // visible until invalidateQueries' background refetch completes, and a
  266. // quick re-click on the same row opens a second delete-confirm modal
  267. // that resolves to a 404 (server already deleted it). The cache holds a
  268. // grouped response (filament / printer / process), not a flat list.
  269. queryClient.setQueryData<LocalPresetsResponse>(['localPresets'], (old) => {
  270. if (!old) return old;
  271. return {
  272. filament: old.filament.filter((p) => p.id !== id),
  273. printer: old.printer.filter((p) => p.id !== id),
  274. process: old.process.filter((p) => p.id !== id),
  275. };
  276. });
  277. queryClient.invalidateQueries({ queryKey: ['localPresets'] });
  278. // Match the import path: the SliceModal's `slicerPresets` query needs
  279. // to be invalidated too, otherwise the deleted preset keeps appearing
  280. // in the slice dropdown until its 60s staleTime expires plus a
  281. // refocus / remount (#1581).
  282. queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
  283. setDeleteConfirm(null);
  284. showToast(t('profiles.localProfiles.toast.deleted'));
  285. },
  286. });
  287. const handleFiles = useCallback((files: FileList | null) => {
  288. if (!files || files.length === 0) return;
  289. importMutation.mutate(files);
  290. }, [importMutation]);
  291. const handleDrop = useCallback((e: React.DragEvent) => {
  292. e.preventDefault();
  293. setIsDragging(false);
  294. handleFiles(e.dataTransfer.files);
  295. }, [handleFiles]);
  296. const filterPresets = useCallback((list: LocalPreset[]) => {
  297. if (!searchQuery) return list;
  298. const q = searchQuery.toLowerCase();
  299. return list.filter(p =>
  300. p.name.toLowerCase().includes(q) ||
  301. p.filament_type?.toLowerCase().includes(q) ||
  302. p.filament_vendor?.toLowerCase().includes(q)
  303. );
  304. }, [searchQuery]);
  305. const filaments = useMemo(() => filterPresets(presets?.filament || []), [presets?.filament, filterPresets]);
  306. const printers = useMemo(() => filterPresets(presets?.printer || []), [presets?.printer, filterPresets]);
  307. const processes = useMemo(() => filterPresets(presets?.process || []), [presets?.process, filterPresets]);
  308. const totalCount = filaments.length + printers.length + processes.length;
  309. // Count of imported presets BEFORE the search filter — drives whether the
  310. // search bar shows at all. Gating the search bar on totalCount (post-filter)
  311. // made it vanish the moment a query matched nothing, leaving the user unable
  312. // to clear or edit their search without a page refresh (#1470).
  313. const hasAnyPresets =
  314. (presets?.filament?.length ?? 0) +
  315. (presets?.printer?.length ?? 0) +
  316. (presets?.process?.length ?? 0) >
  317. 0;
  318. if (isLoading) {
  319. return (
  320. <div className="flex items-center justify-center py-16">
  321. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  322. </div>
  323. );
  324. }
  325. return (
  326. <div className="space-y-6">
  327. {/* Import Zone */}
  328. {hasPermission('settings:update') && (
  329. <div
  330. onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
  331. onDragLeave={() => setIsDragging(false)}
  332. onDrop={handleDrop}
  333. className={`relative border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
  334. isDragging
  335. ? 'border-bambu-green bg-bambu-green/10'
  336. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  337. }`}
  338. >
  339. <input
  340. type="file"
  341. accept=".json,.zip,.orca_filament,.bbscfg,.bbsflmt"
  342. multiple
  343. className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
  344. onChange={(e) => handleFiles(e.target.files)}
  345. />
  346. {importMutation.isPending ? (
  347. <div className="flex items-center justify-center gap-2">
  348. <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
  349. <span className="text-bambu-gray">{t('profiles.localProfiles.importing')}</span>
  350. </div>
  351. ) : (
  352. <>
  353. <Upload className="w-8 h-8 text-bambu-gray mx-auto mb-2" />
  354. <p className="text-sm text-white font-medium">{t('profiles.localProfiles.import')}</p>
  355. <p className="text-xs text-bambu-gray mt-1">{t('profiles.localProfiles.importDesc')}</p>
  356. </>
  357. )}
  358. </div>
  359. )}
  360. {/* Search Bar */}
  361. {hasAnyPresets && (
  362. <div className="relative">
  363. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  364. <input
  365. type="text"
  366. value={searchQuery}
  367. onChange={(e) => setSearchQuery(e.target.value)}
  368. placeholder={t('profiles.localProfiles.search')}
  369. className="w-full pl-9 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  370. />
  371. </div>
  372. )}
  373. {/* No presets imported at all */}
  374. {!hasAnyPresets && !isLoading && (
  375. <div className="text-center py-12">
  376. <HardDrive className="w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50" />
  377. <p className="text-bambu-gray">{t('profiles.localProfiles.noPresets')}</p>
  378. <p className="text-xs text-bambu-gray/60 mt-1">{t('profiles.localProfiles.importDesc')}</p>
  379. </div>
  380. )}
  381. {/* Presets exist, but the search query matched none of them */}
  382. {hasAnyPresets && totalCount === 0 && !isLoading && (
  383. <div className="text-center py-12">
  384. <Search className="w-12 h-12 text-bambu-gray mx-auto mb-3 opacity-50" />
  385. <p className="text-bambu-gray">{t('profiles.localProfiles.noSearchResults')}</p>
  386. </div>
  387. )}
  388. {/* 3-Column Preset Lists */}
  389. {totalCount > 0 && (
  390. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  391. {/* Filament Column */}
  392. {filaments.length > 0 && (
  393. <div>
  394. <div className="flex items-center gap-2 mb-3">
  395. <Droplet className="w-4 h-4 text-bambu-green" />
  396. <h3 className="text-sm font-medium text-white">
  397. {t('profiles.localProfiles.filament')}
  398. </h3>
  399. <span className="text-xs text-bambu-gray">({filaments.length})</span>
  400. </div>
  401. <div className="space-y-2">
  402. {filaments.map(p => (
  403. <PresetCard
  404. key={p.id}
  405. preset={p}
  406. onDelete={(id) => setDeleteConfirm(id)}
  407. onExpand={setExpandedId}
  408. isExpanded={expandedId === p.id}
  409. />
  410. ))}
  411. </div>
  412. </div>
  413. )}
  414. {/* Process Column */}
  415. {processes.length > 0 && (
  416. <div>
  417. <div className="flex items-center gap-2 mb-3">
  418. <Layers className="w-4 h-4 text-blue-600 dark:text-blue-400" />
  419. <h3 className="text-sm font-medium text-white">
  420. {t('profiles.localProfiles.process')}
  421. </h3>
  422. <span className="text-xs text-bambu-gray">({processes.length})</span>
  423. </div>
  424. <div className="space-y-2">
  425. {processes.map(p => (
  426. <PresetCard
  427. key={p.id}
  428. preset={p}
  429. onDelete={(id) => setDeleteConfirm(id)}
  430. onExpand={setExpandedId}
  431. isExpanded={expandedId === p.id}
  432. />
  433. ))}
  434. </div>
  435. </div>
  436. )}
  437. {/* Printer Column */}
  438. {printers.length > 0 && (
  439. <div>
  440. <div className="flex items-center gap-2 mb-3">
  441. <Settings2 className="w-4 h-4 text-orange-600 dark:text-orange-400" />
  442. <h3 className="text-sm font-medium text-white">
  443. {t('profiles.localProfiles.printer')}
  444. </h3>
  445. <span className="text-xs text-bambu-gray">({printers.length})</span>
  446. </div>
  447. <div className="space-y-2">
  448. {printers.map(p => (
  449. <PresetCard
  450. key={p.id}
  451. preset={p}
  452. onDelete={(id) => setDeleteConfirm(id)}
  453. onExpand={setExpandedId}
  454. isExpanded={expandedId === p.id}
  455. />
  456. ))}
  457. </div>
  458. </div>
  459. )}
  460. </div>
  461. )}
  462. {/* Delete Confirmation Modal */}
  463. {deleteConfirm !== null && (
  464. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
  465. <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-6 max-w-sm mx-4">
  466. <div className="flex items-center gap-2 mb-3">
  467. <AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
  468. <h3 className="text-white font-medium">{t('profiles.localProfiles.deleteConfirmTitle')}</h3>
  469. </div>
  470. <p className="text-sm text-bambu-gray mb-4">{t('profiles.localProfiles.deleteConfirm')}</p>
  471. <div className="flex justify-end gap-2">
  472. <Button variant="secondary" size="sm" onClick={() => setDeleteConfirm(null)}>
  473. {t('profiles.localProfiles.cancel')}
  474. </Button>
  475. <Button
  476. variant="danger"
  477. size="sm"
  478. onClick={() => deleteMutation.mutate(deleteConfirm)}
  479. disabled={deleteMutation.isPending}
  480. >
  481. {deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
  482. {t('profiles.localProfiles.delete')}
  483. </Button>
  484. </div>
  485. </div>
  486. </div>
  487. )}
  488. </div>
  489. );
  490. }