SliceSpoolPicker.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import { useEffect, useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { X } from 'lucide-react';
  4. import type { LoadedSpoolPrinter, LoadedSpoolTray, UnifiedPresetsResponse } from '../api/client';
  5. import { FilamentSwatch } from './FilamentSwatch';
  6. import { formatSlotLabel, getAmsLabel, getEmptySlotKind } from '../utils/amsHelpers';
  7. import {
  8. matchSlotPreset,
  9. presetDisplayName,
  10. savedPresetFor,
  11. trayColourHex,
  12. type FilamentNameIndex,
  13. type SlotMatch,
  14. } from '../utils/sliceLoadedSpools';
  15. import type { PrinterCompatibilityIndex } from '../utils/slicerPrinterMatch';
  16. interface SliceSpoolPickerProps {
  17. /** Connected printers to offer, already narrowed to the selected model. */
  18. printers: LoadedSpoolPrinter[];
  19. data: UnifiedPresetsResponse;
  20. index: FilamentNameIndex;
  21. selectedPrinterName: string | null;
  22. compatIndex: PrinterCompatibilityIndex;
  23. /** The filament row being filled, e.g. "Filament 2 (PLA)". */
  24. slotLabel: string;
  25. onPick: (match: SlotMatch, colour: string | null) => void;
  26. onClose: () => void;
  27. }
  28. /**
  29. * The spools loaded in the connected printers, laid out like the units
  30. * themselves, for filling one filament row of the Slice dialog (#3172).
  31. *
  32. * Regular AMS units keep their four-slot grid, empty slots included, so the
  33. * screen matches the machine. AMS-HT units follow, packed four to a row, then
  34. * the external holders that have a spool in them. A loaded spool with no
  35. * profile for the selected printer stays visible but can't be picked.
  36. */
  37. export function SliceSpoolPicker({
  38. printers,
  39. data,
  40. index,
  41. selectedPrinterName,
  42. compatIndex,
  43. slotLabel,
  44. onPick,
  45. onClose,
  46. }: SliceSpoolPickerProps) {
  47. const { t } = useTranslation();
  48. useEffect(() => {
  49. const onKey = (e: KeyboardEvent) => {
  50. if (e.key === 'Escape') onClose();
  51. };
  52. window.addEventListener('keydown', onKey);
  53. return () => window.removeEventListener('keydown', onKey);
  54. }, [onClose]);
  55. return (
  56. <div
  57. className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
  58. onClick={onClose}
  59. >
  60. <div
  61. role="dialog"
  62. aria-modal="true"
  63. aria-labelledby="slice-spool-picker-title"
  64. className="w-full max-w-3xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
  65. onClick={(e) => e.stopPropagation()}
  66. >
  67. <div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
  68. <div className="min-w-0">
  69. <h3 id="slice-spool-picker-title" className="text-white font-medium">
  70. {t('slice.loadedSpools.title', 'Loaded spools')}
  71. </h3>
  72. <p className="text-xs text-bambu-gray mt-1 truncate">{slotLabel}</p>
  73. </div>
  74. <button
  75. type="button"
  76. onClick={onClose}
  77. className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors"
  78. aria-label={t('common.close')}
  79. >
  80. <X className="w-5 h-5" />
  81. </button>
  82. </div>
  83. <div className="flex-1 overflow-y-auto p-4 space-y-5">
  84. {printers.length === 0 ? (
  85. <p className="text-sm text-bambu-gray">
  86. {t('slice.loadedSpools.none', 'No printer of this model is online.')}
  87. </p>
  88. ) : (
  89. printers.map((printer) => (
  90. <PrinterSection
  91. key={printer.id}
  92. printer={printer}
  93. matchFor={(tray) => matchSlotPreset(tray, data, index, selectedPrinterName, compatIndex)}
  94. onPick={onPick}
  95. />
  96. ))
  97. )}
  98. </div>
  99. </div>
  100. </div>
  101. );
  102. }
  103. function PrinterSection({
  104. printer,
  105. matchFor,
  106. onPick,
  107. }: {
  108. printer: LoadedSpoolPrinter;
  109. matchFor: (tray: LoadedSpoolTray) => SlotMatch | null;
  110. onPick: (match: SlotMatch, colour: string | null) => void;
  111. }) {
  112. const { t } = useTranslation();
  113. const regular = printer.ams.filter((u) => !u.is_ams_ht);
  114. const ht = printer.ams.filter((u) => u.is_ams_ht);
  115. const hasAny = printer.ams.length > 0 || printer.external.length > 0;
  116. const tile = (tray: LoadedSpoolTray, label: string) => (
  117. <TrayTile key={`${tray.ams_id}-${tray.tray_id}`} tray={tray} label={label} match={matchFor(tray)} onPick={onPick} />
  118. );
  119. return (
  120. <section>
  121. <h4 className="text-sm text-white font-medium mb-2">
  122. {printer.name}
  123. {printer.model && <span className="ml-2 text-xs text-bambu-gray font-normal">{printer.model}</span>}
  124. </h4>
  125. {!hasAny && (
  126. <p className="text-xs text-bambu-gray">{t('slice.loadedSpools.nothingLoaded', 'No AMS and no external spool.')}</p>
  127. )}
  128. <div className="space-y-3">
  129. {regular.map((unit) => (
  130. <div key={unit.id}>
  131. <div className="text-xs text-bambu-gray mb-1">{getAmsLabel(unit.id)}</div>
  132. <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
  133. {unit.trays.map((tray) => tile(tray, formatSlotLabel(unit.id, tray.tray_id, false, false)))}
  134. </div>
  135. </div>
  136. ))}
  137. {ht.length > 0 && (
  138. <div>
  139. <div className="text-xs text-bambu-gray mb-1">AMS-HT</div>
  140. <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
  141. {ht.flatMap((unit) => unit.trays.map((tray) => tile(tray, formatSlotLabel(unit.id, tray.tray_id, true, false))))}
  142. </div>
  143. </div>
  144. )}
  145. {printer.external.length > 0 && (
  146. <div>
  147. <div className="text-xs text-bambu-gray mb-1">{t('printers.external')}</div>
  148. <div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
  149. {printer.external.map((tray) =>
  150. tile(
  151. tray,
  152. printer.external_holders > 1
  153. ? (tray.tray_id === 0 ? t('printers.extL') : t('printers.extR'))
  154. : t('printers.ext'),
  155. ),
  156. )}
  157. </div>
  158. </div>
  159. )}
  160. </div>
  161. </section>
  162. );
  163. }
  164. function TrayTile({
  165. tray,
  166. label,
  167. match,
  168. onPick,
  169. }: {
  170. tray: LoadedSpoolTray;
  171. label: string;
  172. match: SlotMatch | null;
  173. onPick: (match: SlotMatch, colour: string | null) => void;
  174. }) {
  175. const { t } = useTranslation();
  176. const emptyKind = getEmptySlotKind(tray);
  177. const colour = trayColourHex(tray);
  178. const profileName = useMemo(() => {
  179. if (match) return presetDisplayName(match.preset.name);
  180. const saved = savedPresetFor(tray);
  181. if (saved?.preset_name) return presetDisplayName(saved.preset_name);
  182. return tray.tray_sub_brands || null;
  183. }, [match, tray]);
  184. if (emptyKind !== null) {
  185. return (
  186. <div className="rounded-md border border-dashed border-bambu-dark-tertiary px-2 py-2 text-xs text-bambu-gray/70">
  187. <div className="font-medium text-bambu-gray">{label}</div>
  188. <div>
  189. {emptyKind === 'reset'
  190. ? t('slice.loadedSpools.unknownSpool', 'Unidentified spool')
  191. : t('slice.loadedSpools.empty', 'Empty')}
  192. </div>
  193. </div>
  194. );
  195. }
  196. const disabled = match === null;
  197. return (
  198. <button
  199. type="button"
  200. disabled={disabled}
  201. onClick={() => match && onPick(match, colour)}
  202. title={disabled ? t('slice.loadedSpools.noProfile', 'No profile for the selected printer') : profileName ?? undefined}
  203. className="text-left rounded-md border border-bambu-dark-tertiary bg-bambu-dark px-2 py-2 text-xs transition-colors enabled:hover:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed"
  204. >
  205. <div className="flex items-center gap-1.5">
  206. <FilamentSwatch rgba={tray.tray_color} className="w-3.5 h-3.5 shrink-0" effectSize="table" />
  207. <span className="font-medium text-white">{label}</span>
  208. <span className="ml-auto text-bambu-gray">{tray.tray_type}</span>
  209. </div>
  210. <div className="mt-1 truncate text-bambu-gray" title={profileName ?? undefined}>
  211. {profileName ?? '—'}
  212. </div>
  213. {disabled && (
  214. <div className="mt-0.5 truncate text-[11px] text-amber-600 dark:text-amber-400">
  215. {t('slice.loadedSpools.noProfileShort', 'No profile for this printer')}
  216. </div>
  217. )}
  218. </button>
  219. );
  220. }