LabelTemplatePickerModal.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { X, Loader2, Printer, CheckSquare, Square, Search } from 'lucide-react';
  4. import { api, type SpoolLabelTemplate, type InventorySpool } from '../api/client';
  5. import { Button } from './Button';
  6. import { useToast } from '../contexts/ToastContext';
  7. import { getSwatchStyle } from '../utils/colors';
  8. /** Subset of InventorySpool the modal needs for checkbox rendering. */
  9. type SpoolForLabel = Pick<
  10. InventorySpool,
  11. 'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'rgba'
  12. >;
  13. interface LabelTemplatePickerModalProps {
  14. isOpen: boolean;
  15. onClose: () => void;
  16. /** All spools the modal can choose from. Typically the page's current
  17. * filter result so the modal stays consistent with what the user sees. */
  18. availableSpools: SpoolForLabel[];
  19. /** IDs to pre-check when the modal opens. Per-card icon passes a single ID;
  20. * the bulk header button passes every visible ID so the user lands in
  21. * "all checked" and refines downward. */
  22. initialSelectedIds: number[];
  23. spoolmanMode: boolean;
  24. }
  25. interface TemplateOption {
  26. value: SpoolLabelTemplate;
  27. i18nKey: string;
  28. fallbackLabel: string;
  29. fallbackHint: string;
  30. }
  31. const TEMPLATE_OPTIONS: TemplateOption[] = [
  32. {
  33. value: 'ams_holder_74x33',
  34. i18nKey: 'amsHolderSmall',
  35. fallbackLabel: 'AMS holder — small (74 × 33 mm)',
  36. fallbackHint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
  37. },
  38. {
  39. value: 'ams_holder_75x55',
  40. i18nKey: 'amsHolderLarge',
  41. fallbackLabel: 'AMS holder — large (75 × 55 mm)',
  42. fallbackHint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
  43. },
  44. {
  45. value: 'box_40x30',
  46. i18nKey: 'box40x30',
  47. fallbackLabel: 'Box label (40 × 30 mm)',
  48. fallbackHint: 'Single label per page; common DK/Brother roll size, good for filament-bag and storage-bin labels.',
  49. },
  50. {
  51. value: 'box_62x29',
  52. i18nKey: 'box',
  53. fallbackLabel: 'Box label (62 × 29 mm)',
  54. fallbackHint: 'Single label per page; sized for Brother PT/QL and Dymo small labels.',
  55. },
  56. {
  57. value: 'avery_l7160',
  58. i18nKey: 'averyL7160',
  59. fallbackLabel: 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
  60. fallbackHint: 'EU sheet stock; 21 labels per A4 page.',
  61. },
  62. {
  63. value: 'avery_5160',
  64. i18nKey: 'avery5160',
  65. fallbackLabel: 'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
  66. fallbackHint: 'US sheet stock; 30 labels per Letter page.',
  67. },
  68. ];
  69. function openBlobInNewTab(blob: Blob): void {
  70. const url = window.URL.createObjectURL(blob);
  71. // Do NOT pass `noopener,noreferrer`: per the WindowFeatures spec, `noopener`
  72. // forces window.open to return `null` even on success, which made the
  73. // `if (!win)` popup-block fallback below fire on EVERY click — so the blob
  74. // tab opened (downloading a random-named PDF on systems without an inline
  75. // viewer) AND the `<a download>` fallback fired (downloading a second copy
  76. // named bambuddy-labels.pdf). Two identical PDFs per click — issue #1628.
  77. // The blob is same-origin, the destination is a passive PDF tab with no
  78. // script context, and `noreferrer` is a no-op for blob URLs, so dropping
  79. // these flags has no security impact.
  80. const win = window.open(url, '_blank');
  81. if (!win) {
  82. const a = document.createElement('a');
  83. a.href = url;
  84. a.download = 'bambuddy-labels.pdf';
  85. document.body.appendChild(a);
  86. a.click();
  87. document.body.removeChild(a);
  88. }
  89. setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
  90. }
  91. // Thin wrapper over `getSwatchStyle` from utils/colors so the modal's render
  92. // sites keep their existing call shape. Transparent (alpha=00) spools now
  93. // render as a checkerboard pattern instead of collapsing to solid black
  94. // (#1545).
  95. function swatchStyle(rgba: string | null | undefined): React.CSSProperties {
  96. return getSwatchStyle(rgba);
  97. }
  98. function spoolDisplayName(s: SpoolForLabel): string {
  99. const head = s.color_name ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
  100. const brand = s.brand ? ` · ${s.brand}` : '';
  101. return `${head}${brand}`;
  102. }
  103. /** Build a lowercased haystack that the search input matches against. */
  104. function searchableText(s: SpoolForLabel): string {
  105. return [s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
  106. .filter(Boolean)
  107. .join(' ')
  108. .toLowerCase();
  109. }
  110. type SortMode = 'id' | 'color';
  111. /** Sort key for the "by colour" mode (#1410).
  112. *
  113. * Returns a 2-tuple so JS array compare does the right thing without us having
  114. * to spell out a comparator: ``[bucket, position]``. Chromatic colours
  115. * (saturation above the threshold) go in bucket 0 ordered by HSL hue, so the
  116. * sheet reads as a continuous rainbow. Achromatic colours (white / grey /
  117. * black, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the
  118. * neutrals trail at the end of the rainbow going dark → light. Multi-colour
  119. * spools sort on their primary ``rgba``; their ``extra_colors`` stripe is
  120. * still rendered on the label itself but doesn't drive the sort.
  121. */
  122. function colorSortKey(rgba: string | null | undefined): [number, number] {
  123. if (!rgba) return [1, 0]; // unknown colour — bucket with the neutrals at black
  124. const cleaned = rgba.replace(/^#/, '').slice(0, 6);
  125. if (cleaned.length !== 6) return [1, 0];
  126. const r = parseInt(cleaned.slice(0, 2), 16);
  127. const g = parseInt(cleaned.slice(2, 4), 16);
  128. const b = parseInt(cleaned.slice(4, 6), 16);
  129. if ([r, g, b].some(Number.isNaN)) return [1, 0];
  130. const rn = r / 255;
  131. const gn = g / 255;
  132. const bn = b / 255;
  133. const max = Math.max(rn, gn, bn);
  134. const min = Math.min(rn, gn, bn);
  135. const l = (max + min) / 2;
  136. const delta = max - min;
  137. // Saturation in the HSL definition. Achromatic cutoff at 0.1 is generous —
  138. // matches what feels "grey enough" to a user picking colours, without
  139. // sending dark muted colours like deep navy into the neutrals bucket.
  140. const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
  141. if (s < 0.1) return [1, l]; // neutrals: ordered black → white
  142. let h = 0;
  143. if (max === rn) h = ((gn - bn) / delta) % 6;
  144. else if (max === gn) h = (bn - rn) / delta + 2;
  145. else h = (rn - gn) / delta + 4;
  146. h = h * 60;
  147. if (h < 0) h += 360;
  148. return [0, h]; // chromatic: ordered by hue 0..360
  149. }
  150. export function LabelTemplatePickerModal({
  151. isOpen,
  152. onClose,
  153. availableSpools,
  154. initialSelectedIds,
  155. spoolmanMode,
  156. }: LabelTemplatePickerModalProps) {
  157. const { t } = useTranslation();
  158. const { showToast } = useToast();
  159. const [pending, setPending] = useState<SpoolLabelTemplate | null>(null);
  160. const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
  161. const [search, setSearch] = useState('');
  162. const [materialFilter, setMaterialFilter] = useState<string>('');
  163. const [sortMode, setSortMode] = useState<SortMode>('id');
  164. const [monochrome, setMonochrome] = useState(false);
  165. // Sync from caller and reset transient state on open. Intentionally not
  166. // reactive to props while open — once the user starts editing we don't want
  167. // a parent re-render to clobber their selection / filter / search.
  168. useEffect(() => {
  169. if (isOpen) {
  170. const allowed = new Set(availableSpools.map((s) => s.id));
  171. setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id))));
  172. setSearch('');
  173. setMaterialFilter('');
  174. setSortMode('id');
  175. setMonochrome(false);
  176. setPending(null);
  177. }
  178. // eslint-disable-next-line react-hooks/exhaustive-deps
  179. }, [isOpen]);
  180. const sortedSpools = useMemo(() => {
  181. const copy = [...availableSpools];
  182. if (sortMode === 'color') {
  183. copy.sort((a, b) => {
  184. const ka = colorSortKey(a.rgba);
  185. const kb = colorSortKey(b.rgba);
  186. if (ka[0] !== kb[0]) return ka[0] - kb[0];
  187. if (ka[1] !== kb[1]) return ka[1] - kb[1];
  188. // Stable tiebreaker on ID so identical colours print in a deterministic
  189. // order across renders.
  190. return a.id - b.id;
  191. });
  192. return copy;
  193. }
  194. copy.sort((a, b) => a.id - b.id);
  195. return copy;
  196. }, [availableSpools, sortMode]);
  197. // Material chips are derived from the *full* available set so they stay
  198. // stable when search/material filter narrows the visible list.
  199. const materials = useMemo(() => {
  200. const set = new Set<string>();
  201. for (const s of sortedSpools) {
  202. if (s.material) set.add(s.material.toUpperCase());
  203. }
  204. return [...set].sort();
  205. }, [sortedSpools]);
  206. const visibleSpools = useMemo(() => {
  207. const q = search.trim().toLowerCase();
  208. return sortedSpools.filter((s) => {
  209. if (materialFilter && (s.material || '').toUpperCase() !== materialFilter) return false;
  210. if (q && !searchableText(s).includes(q)) return false;
  211. return true;
  212. });
  213. }, [sortedSpools, search, materialFilter]);
  214. const allVisibleChecked =
  215. visibleSpools.length > 0 && visibleSpools.every((s) => selectedIds.has(s.id));
  216. if (!isOpen) return null;
  217. const selectedCount = selectedIds.size;
  218. const noSelection = selectedCount === 0;
  219. function toggleOne(id: number) {
  220. setSelectedIds((prev) => {
  221. const next = new Set(prev);
  222. if (next.has(id)) next.delete(id);
  223. else next.add(id);
  224. return next;
  225. });
  226. }
  227. function selectAllVisible() {
  228. setSelectedIds((prev) => {
  229. const next = new Set(prev);
  230. for (const s of visibleSpools) next.add(s.id);
  231. return next;
  232. });
  233. }
  234. function deselectVisible() {
  235. setSelectedIds((prev) => {
  236. const next = new Set(prev);
  237. for (const s of visibleSpools) next.delete(s.id);
  238. return next;
  239. });
  240. }
  241. function clearAll() {
  242. setSelectedIds(new Set());
  243. }
  244. async function handlePick(template: SpoolLabelTemplate) {
  245. if (noSelection || pending) return;
  246. // Order matters: the backend (labels.py) prints labels in the same order
  247. // we send IDs. Use the sorted list so a "by colour" sort flows through to
  248. // the PDF instead of being clobbered by an ascending-ID re-sort.
  249. const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id);
  250. setPending(template);
  251. try {
  252. const blob = spoolmanMode
  253. ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template, monochrome })
  254. : await api.printSpoolLabels({ spool_ids: ids, template, monochrome });
  255. openBlobInNewTab(blob);
  256. onClose();
  257. } catch (err) {
  258. const msg = err instanceof Error ? err.message : String(err);
  259. showToast(
  260. t('inventory.labels.error', 'Could not generate labels: {{msg}}', { msg }),
  261. 'error',
  262. );
  263. } finally {
  264. setPending(null);
  265. }
  266. }
  267. return (
  268. <div className="fixed inset-0 z-50 flex items-start sm:items-center justify-center p-4 overflow-y-auto">
  269. <div
  270. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  271. onClick={onClose}
  272. />
  273. <div className="relative w-full max-w-3xl bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
  274. {/* Header */}
  275. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  276. <div className="flex items-center gap-2">
  277. <Printer className="w-5 h-5 text-bambu-green" />
  278. <h2 className="text-lg font-semibold text-white">
  279. {t('inventory.labels.title', 'Print spool labels')}
  280. </h2>
  281. {selectedCount > 0 && (
  282. <span className="text-sm text-bambu-gray">
  283. ({t('inventory.labels.selectedCount', '{{count}} selected', { count: selectedCount })})
  284. </span>
  285. )}
  286. </div>
  287. <button
  288. onClick={onClose}
  289. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  290. aria-label={t('common.close', 'Close')}
  291. >
  292. <X className="w-5 h-5" />
  293. </button>
  294. </div>
  295. {/* Search + material chips */}
  296. <div className="p-4 space-y-2 border-b border-bambu-dark-tertiary">
  297. <div className="relative">
  298. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  299. <input
  300. type="search"
  301. value={search}
  302. onChange={(e) => setSearch(e.target.value)}
  303. placeholder={t('inventory.labels.searchPlaceholder', 'Search name, brand, or #ID')}
  304. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
  305. />
  306. </div>
  307. {materials.length > 1 && (
  308. <div className="flex flex-wrap items-center gap-1.5">
  309. <span className="text-xs text-bambu-gray mr-1">
  310. {t('inventory.labels.filterByMaterial', 'Material:')}
  311. </span>
  312. <button
  313. type="button"
  314. onClick={() => setMaterialFilter('')}
  315. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  316. materialFilter === ''
  317. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  318. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  319. }`}
  320. >
  321. {t('inventory.labels.allMaterials', 'All')}
  322. </button>
  323. {materials.map((m) => (
  324. <button
  325. key={m}
  326. type="button"
  327. onClick={() => setMaterialFilter(m)}
  328. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  329. materialFilter === m
  330. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  331. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  332. }`}
  333. >
  334. {m}
  335. </button>
  336. ))}
  337. </div>
  338. )}
  339. <div className="flex flex-wrap items-center gap-1.5">
  340. <span className="text-xs text-bambu-gray mr-1">
  341. {t('inventory.labels.sortBy.label')}
  342. </span>
  343. <button
  344. type="button"
  345. onClick={() => setSortMode('id')}
  346. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  347. sortMode === 'id'
  348. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  349. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  350. }`}
  351. >
  352. {t('inventory.labels.sortBy.id')}
  353. </button>
  354. <button
  355. type="button"
  356. onClick={() => setSortMode('color')}
  357. className={`px-2 py-0.5 text-xs rounded-full border transition ${
  358. sortMode === 'color'
  359. ? 'bg-bambu-green text-bambu-dark border-bambu-green'
  360. : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
  361. }`}
  362. >
  363. {t('inventory.labels.sortBy.color')}
  364. </button>
  365. </div>
  366. </div>
  367. {/* Action bar */}
  368. <div className="px-4 pt-3 pb-2 flex items-center justify-between gap-3 flex-wrap">
  369. <span className="text-sm text-bambu-gray">
  370. {t('inventory.labels.pickSpools', 'Pick which spools to print labels for:')}
  371. </span>
  372. <div className="flex items-center gap-3 text-xs">
  373. <button
  374. type="button"
  375. onClick={allVisibleChecked ? deselectVisible : selectAllVisible}
  376. disabled={visibleSpools.length === 0}
  377. className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
  378. >
  379. {allVisibleChecked
  380. ? t('inventory.labels.deselectVisible', 'Deselect visible')
  381. : t('inventory.labels.selectVisible', 'Select all visible ({{count}})', {
  382. count: visibleSpools.length,
  383. })}
  384. </button>
  385. <button
  386. type="button"
  387. onClick={clearAll}
  388. disabled={selectedCount === 0}
  389. className="text-bambu-gray hover:text-white hover:underline disabled:opacity-50 disabled:no-underline disabled:cursor-not-allowed"
  390. >
  391. {t('inventory.labels.clearAll', 'Clear all')}
  392. </button>
  393. </div>
  394. </div>
  395. {/* Spool list */}
  396. <div className="flex-1 overflow-y-auto px-2 pb-2 min-h-0">
  397. {visibleSpools.length === 0 ? (
  398. <div className="text-center text-sm text-bambu-gray py-6">
  399. {sortedSpools.length === 0
  400. ? t('inventory.labels.noSpoolsToShow', 'No spools to show. Adjust your filter and try again.')
  401. : t('inventory.labels.noMatches', 'No spools match the current search or filter.')}
  402. </div>
  403. ) : (
  404. <ul className="space-y-0.5">
  405. {visibleSpools.map((s) => {
  406. const checked = selectedIds.has(s.id);
  407. return (
  408. <li key={s.id}>
  409. <label className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary/50 cursor-pointer">
  410. {checked ? (
  411. <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
  412. ) : (
  413. <Square className="w-4 h-4 text-bambu-gray shrink-0" />
  414. )}
  415. <input
  416. type="checkbox"
  417. checked={checked}
  418. onChange={() => toggleOne(s.id)}
  419. className="sr-only"
  420. />
  421. <span
  422. className="w-4 h-4 rounded border border-black/20 shrink-0"
  423. style={swatchStyle(s.rgba)}
  424. />
  425. <span className="flex-1 min-w-0 truncate text-sm text-white">
  426. {spoolDisplayName(s)}
  427. </span>
  428. <span className="text-xs font-mono text-bambu-gray shrink-0">
  429. #{s.id}
  430. </span>
  431. </label>
  432. </li>
  433. );
  434. })}
  435. </ul>
  436. )}
  437. </div>
  438. {/* Print options */}
  439. <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary">
  440. <label className="inline-flex items-center gap-2 cursor-pointer select-none">
  441. {monochrome ? (
  442. <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
  443. ) : (
  444. <Square className="w-4 h-4 text-bambu-gray shrink-0" />
  445. )}
  446. <input
  447. type="checkbox"
  448. checked={monochrome}
  449. onChange={(e) => setMonochrome(e.target.checked)}
  450. className="sr-only"
  451. />
  452. <span className="text-sm text-white">
  453. {t('inventory.labels.monochrome', 'Monochrome (black & white printer)')}
  454. </span>
  455. <span className="text-xs text-bambu-gray">
  456. {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
  457. </span>
  458. </label>
  459. </div>
  460. {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
  461. inside max-h-[90vh] even when browser chrome eats into the viewport
  462. (#1230). Stacked single column on mobile widths. */}
  463. <div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
  464. {TEMPLATE_OPTIONS.map((opt) => {
  465. const isPending = pending === opt.value;
  466. const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);
  467. const hint = t(`inventory.labels.templates.${opt.i18nKey}.hint`, opt.fallbackHint);
  468. return (
  469. <button
  470. key={opt.value}
  471. disabled={noSelection || pending !== null}
  472. onClick={() => handlePick(opt.value)}
  473. title={`${label} — ${hint}`}
  474. className="w-full text-left p-2.5 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-green hover:bg-bambu-green/10 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-bambu-dark-tertiary disabled:hover:bg-bambu-dark transition flex items-center gap-3"
  475. >
  476. <div className="flex-1 min-w-0">
  477. <div className="font-medium text-white text-sm truncate">{label}</div>
  478. <div className="text-xs text-bambu-gray mt-0.5 truncate">{hint}</div>
  479. </div>
  480. {isPending && <Loader2 className="w-4 h-4 animate-spin text-bambu-green shrink-0" />}
  481. </button>
  482. );
  483. })}
  484. </div>
  485. <div className="flex justify-end gap-2 px-5 py-2 border-t border-bambu-dark-tertiary">
  486. <Button variant="secondary" onClick={onClose} disabled={pending !== null}>
  487. {t('common.cancel', 'Cancel')}
  488. </Button>
  489. </div>
  490. </div>
  491. </div>
  492. );
  493. }