SliceModal.tsx 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085
  1. import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
  2. import { useEffect, useMemo, useState } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  5. import {
  6. api,
  7. type PresetRef,
  8. type PresetSource,
  9. type SliceJobProgress,
  10. type SliceRequest,
  11. type SlicerCloudStatus,
  12. type UnifiedPreset,
  13. type UnifiedPresetsBySlot,
  14. type UnifiedPresetsResponse,
  15. } from '../api/client';
  16. import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
  17. import { useToast } from '../contexts/ToastContext';
  18. import { PlatePickerModal } from './PlatePickerModal';
  19. import type { PlateFilament } from '../types/plates';
  20. import {
  21. presetCompatibility,
  22. buildCompatibilityIndex,
  23. EMPTY_COMPATIBILITY_INDEX,
  24. type PrinterCompatibilityIndex,
  25. } from '../utils/slicerPrinterMatch';
  26. import {
  27. findPreset,
  28. findPresetByName,
  29. pickDefault,
  30. pickFilamentForSlot,
  31. pickProcessDefault,
  32. type Slot,
  33. } from '../utils/slicePresetPicker';
  34. export type SliceSource =
  35. | { kind: 'libraryFile'; id: number; filename: string }
  36. | { kind: 'archive'; id: number; filename: string };
  37. interface SliceModalProps {
  38. source: SliceSource;
  39. onClose: () => void;
  40. }
  41. function toRefValue(ref: PresetRef | null): string {
  42. // The HTML `<select>` value space is flat strings; encode source + id so
  43. // the same preset name can live in multiple tiers without collision.
  44. return ref ? `${ref.source}:${ref.id}` : '';
  45. }
  46. function fromRefValue(raw: string): PresetRef | null {
  47. if (!raw) return null;
  48. const idx = raw.indexOf(':');
  49. if (idx < 0) return null;
  50. const source = raw.slice(0, idx) as PresetSource;
  51. const id = raw.slice(idx + 1);
  52. if (source !== 'orca_cloud' && source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
  53. return { source, id };
  54. }
  55. // Inline spinner for the filament-requirements query. The backend runs a
  56. // preview slice on first open of an unsliced project file (cached after);
  57. // on a complex multi-color model that's a real slice — multi-second to
  58. // multi-minute. The static "Analyzing plate filaments…" string left
  59. // users wondering whether anything was happening, so the spinner now
  60. // shows elapsed seconds, polls the sidecar's --pipe progress (via the
  61. // /slicer/preview-progress proxy) for live stage + percent, and after ~5s
  62. // surfaces a "this is a one-time slice — repeat opens are instant"
  63. // note so users don't worry it'll be slow forever.
  64. //
  65. // requestId: a UUID generated by the modal when the filament-requirements
  66. // fetch starts. Forwarded to the sidecar via the API call AND used here
  67. // to poll the matching progress snapshot. Same id, two consumers.
  68. function FilamentAnalysisSpinner({
  69. requestId,
  70. sourceName,
  71. }: {
  72. requestId: string;
  73. sourceName: string;
  74. }) {
  75. const { t } = useTranslation();
  76. const { showPersistentToast, dismissToast } = useToast();
  77. const [elapsed, setElapsed] = useState(0);
  78. const [progress, setProgress] = useState<SliceJobProgress | null>(null);
  79. // Defensive decode — see prettifyFilename comment in SliceJobTrackerContext.
  80. let prettyName = sourceName;
  81. try {
  82. prettyName = decodeURIComponent(sourceName);
  83. } catch {
  84. /* keep raw on malformed encoding */
  85. }
  86. // Elapsed-time tick.
  87. useEffect(() => {
  88. const startedAt = Date.now();
  89. const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
  90. return () => clearInterval(id);
  91. }, []);
  92. // Progress polling — once per second while the spinner is mounted.
  93. // Mirrors the slice-job tracker's cadence. Sidecar 404s during the
  94. // race window between fetch start and progressStore.start() are
  95. // swallowed by the API method (returns null) so we keep polling.
  96. useEffect(() => {
  97. let cancelled = false;
  98. const id = setInterval(async () => {
  99. if (cancelled) return;
  100. const snap = await api.getPreviewSliceProgress(requestId);
  101. if (!cancelled && snap) setProgress(snap);
  102. }, 1000);
  103. return () => {
  104. cancelled = true;
  105. clearInterval(id);
  106. };
  107. }, [requestId]);
  108. // Mirror the spinner's contents into a persistent toast so the user
  109. // sees activity even when their cursor is elsewhere on the page.
  110. // Dismissed in the parent's effect when the requirements arrive.
  111. const toastId = `slice-preview-${requestId}`;
  112. useEffect(() => {
  113. const hasUseful = progress && progress.stage && progress.total_percent > 0;
  114. const elapsedStr = formatElapsed(elapsed);
  115. if (hasUseful) {
  116. showPersistentToast(
  117. toastId,
  118. t(
  119. 'slice.previewWithProgress',
  120. 'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
  121. {
  122. name: prettyName,
  123. stage: progress!.stage,
  124. percent: Math.min(100, Math.max(0, Math.round(progress!.total_percent))),
  125. elapsed: elapsedStr,
  126. },
  127. ),
  128. 'loading',
  129. );
  130. } else {
  131. showPersistentToast(
  132. toastId,
  133. t('slice.previewToast', {
  134. name: prettyName,
  135. elapsed: elapsedStr,
  136. }),
  137. 'loading',
  138. );
  139. }
  140. return () => {
  141. dismissToast(toastId);
  142. };
  143. }, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);
  144. const stage = progress?.stage;
  145. const percent = progress?.total_percent;
  146. const inlineLabel =
  147. stage && typeof percent === 'number' && percent > 0
  148. ? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
  149. : t('slice.analyzingPlateFilaments');
  150. return (
  151. <div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
  152. <div className="flex items-center gap-2">
  153. <Loader2 className="w-4 h-4 animate-spin" />
  154. {inlineLabel}
  155. <span className="text-xs tabular-nums">{elapsed}s</span>
  156. </div>
  157. {elapsed >= 5 && (
  158. <div className="text-xs text-bambu-gray/70 pl-6">
  159. {t(
  160. 'slice.analyzingPlateFilamentsHint',
  161. 'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
  162. )}
  163. </div>
  164. )}
  165. </div>
  166. );
  167. }
  168. function formatElapsed(seconds: number): string {
  169. const s = Math.max(0, Math.floor(seconds));
  170. if (s < 60) return `${s}s`;
  171. const m = Math.floor(s / 60);
  172. const remS = s % 60;
  173. if (m < 60) return `${m}m ${remS}s`;
  174. const h = Math.floor(m / 60);
  175. const remM = m % 60;
  176. return `${h}h ${remM}m`;
  177. }
  178. export function SliceModal({ source, onClose }: SliceModalProps) {
  179. const { t } = useTranslation();
  180. const { trackJob } = useSliceJobTracker();
  181. const queryClient = useQueryClient();
  182. const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
  183. const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
  184. // One filament ref per plate slot, in plate order. For STL / single-plate /
  185. // single-color sources this is a one-element array; multi-color 3MFs get one
  186. // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
  187. // each slot from the source plate's required (type, colour).
  188. const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
  189. const [errorMessage, setErrorMessage] = useState<string | null>(null);
  190. // null = plate not yet picked (or single-plate / non-3MF — picker is skipped
  191. // and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
  192. // the user picks one (or implicitly for single-plate sources).
  193. const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
  194. // "Slice all plates" mode: sends ``plate=0`` to the backend which forwards
  195. // ``--slice 0`` to the BS CLI, producing a single output 3MF whose
  196. // ``Metadata/plate_N.gcode`` entries are *all* plates sliced together —
  197. // one archive, one file, all plates. Distinct from the per-plate
  198. // ``selectedPlate`` mode (which slices just that one plate). Filament
  199. // selection in this mode covers every slot the project defines, not
  200. // just the slots the currently-visible plate happens to use — see
  201. // ``allProjectFilamentSlots`` below.
  202. const [sliceAllPlates, setSliceAllPlates] = useState(false);
  203. // Build-plate override (#1337). null = inherit from the process preset
  204. // (the default). Set to a canonical slicer enum value to patch
  205. // curr_bed_type into the resolved process JSON before slicing — needed
  206. // because the process preset's default plate (typically "Cool Plate") is
  207. // incompatible with high-temp filaments like ABS / ASA / PC, and the
  208. // user had no way to switch plates without cloning the preset.
  209. const [bedType, setBedType] = useState<string | null>(null);
  210. // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
  211. // with one pick, or save the current selection as a new pipeline.
  212. const pipelinesQuery = useQuery({
  213. queryKey: ['slicer-pipelines'],
  214. queryFn: () => api.listSlicerPipelines(),
  215. staleTime: 60_000,
  216. });
  217. const [savePipelineOpen, setSavePipelineOpen] = useState(false);
  218. const [pipelineDraftName, setPipelineDraftName] = useState('');
  219. const { showToast } = useToast();
  220. const createPipelineMutation = useMutation({
  221. mutationFn: (body: {
  222. name: string;
  223. printer_preset: PresetRef;
  224. process_preset: PresetRef;
  225. filament_presets: PresetRef[];
  226. bed_type: string | null;
  227. }) => api.createSlicerPipeline(body),
  228. onSuccess: () => {
  229. queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
  230. showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
  231. setSavePipelineOpen(false);
  232. setPipelineDraftName('');
  233. },
  234. onError: (err: Error) => {
  235. showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
  236. },
  237. });
  238. const platesQuery = useQuery({
  239. queryKey: ['slicePlates', source.kind, source.id],
  240. queryFn: async () => {
  241. if (source.kind === 'libraryFile') {
  242. return api.getLibraryFilePlates(source.id);
  243. }
  244. return api.getArchivePlates(source.id);
  245. },
  246. staleTime: 60_000,
  247. });
  248. const isMultiPlate =
  249. !!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
  250. // Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
  251. // at submit time so the backend's existing default behaviour is preserved.
  252. const needsPlatePicker = isMultiPlate && selectedPlate == null;
  253. // Per-plate filament requirements via the same endpoint the print/schedule
  254. // modal uses. Reusing it here keeps the SliceModal honest with whatever
  255. // logic that endpoint applies (slice_info parsing, future enhancements for
  256. // unsliced project files, dual-nozzle fields, etc.) instead of duplicating
  257. // extraction. plate_id is always sent: single-plate falls through to plate
  258. // 1 server-side; multi-plate uses the user's pick.
  259. const effectivePlateId = selectedPlate ?? 1;
  260. // Generate a request_id per (source, plate) pair so the backend's
  261. // preview-slice and the FilamentAnalysisSpinner's progress poll share
  262. // the same id. useMemo keeps it stable across renders within the same
  263. // pair; switching plates regenerates so a stale poll doesn't bleed
  264. // progress between plates.
  265. const previewRequestId = useMemo(() => {
  266. const random =
  267. typeof crypto !== 'undefined' && 'randomUUID' in crypto
  268. ? crypto.randomUUID()
  269. : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  270. // Tag the id with the (source, plate) so logs/Network panel show which
  271. // pair owns the poll. Also lets the lint rule see the deps in use.
  272. return `${source.kind}-${source.id}-p${effectivePlateId}-${random}`;
  273. }, [source.kind, source.id, effectivePlateId]);
  274. const filamentReqsQuery = useQuery({
  275. queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
  276. queryFn: async () => {
  277. if (source.kind === 'libraryFile') {
  278. return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
  279. }
  280. return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
  281. },
  282. enabled: !needsPlatePicker,
  283. staleTime: 60_000,
  284. });
  285. // Filament slot list for the active plate. Falls back to one synthetic slot
  286. // for STL/STEP and any "no metadata available" case so the modal still
  287. // works (single dropdown, mono-color slice). In ``sliceAllPlates`` mode
  288. // we keep the same slot list (the backend already returns every project
  289. // slot via ``extract_project_filaments_from_3mf``'s fallback path when
  290. // slice_info doesn't carry per-plate filaments) but override every
  291. // slot's ``used_in_plate`` flag to ``true`` so the dropdown labels
  292. // drop the "— not used by this plate" suffix and the dropdowns become
  293. // selectable. Across the whole project, every defined slot IS used by
  294. // at least one plate, so this is correct in slice-all mode.
  295. const filamentSlots = useMemo<PlateFilament[]>(() => {
  296. const reqs = filamentReqsQuery.data?.filaments ?? [];
  297. const base: PlateFilament[] =
  298. reqs.length > 0
  299. ? (reqs as PlateFilament[])
  300. : [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }];
  301. if (sliceAllPlates) {
  302. return base.map((slot) => ({ ...slot, used_in_plate: true }));
  303. }
  304. return base;
  305. }, [sliceAllPlates, filamentReqsQuery.data]);
  306. const presetsQuery = useQuery({
  307. queryKey: ['slicerPresets'],
  308. queryFn: () => api.getSlicerPresets(),
  309. staleTime: 60_000,
  310. // Don't fetch presets while the plate picker is on screen — saves a
  311. // round-trip if the user cancels out of the plate step.
  312. enabled: !platesQuery.isLoading && !needsPlatePicker,
  313. });
  314. // Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
  315. // bundled cache for one call so users who deleted a preset in Bambu
  316. // Studio / Bambu Handy see the change immediately (#1581). The cache write
  317. // inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
  318. // fresh result so subsequent normal callers still get cached responses.
  319. const [isRefreshing, setIsRefreshing] = useState(false);
  320. const handleRefreshPresets = async () => {
  321. if (isRefreshing) return;
  322. setIsRefreshing(true);
  323. try {
  324. const fresh = await api.getSlicerPresets({ refresh: true });
  325. queryClient.setQueryData(['slicerPresets'], fresh);
  326. } catch {
  327. // Fall through to invalidate so React Query retries via its normal
  328. // path on the next render — surfacing the failure through the existing
  329. // presetsQuery.isError banner instead of duplicating error UI here.
  330. queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
  331. } finally {
  332. setIsRefreshing(false);
  333. }
  334. };
  335. // Canonical Bambu printer-model registry — drives the @BBL <code> name
  336. // fallback in slicerPrinterMatch for cloud / standard presets (#1325).
  337. // Long staleTime: the registry only changes across backend releases.
  338. const printerModelsQuery = useQuery({
  339. queryKey: ['slicerPrinterModels'],
  340. queryFn: api.getSlicerPrinterModels,
  341. staleTime: Infinity,
  342. });
  343. // Selected-printer context for the process / filament filter (#1325).
  344. const selectedPrinterName = useMemo<string | null>(() => {
  345. if (!presetsQuery.data || !printerPreset) return null;
  346. return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
  347. }, [presetsQuery.data, printerPreset]);
  348. // Compatibility ground truth: the slicer's own `compatible_printers` list
  349. // on local-imported presets, plus the @BBL <code> name fallback for cloud
  350. // / standard presets via the backend Bambu printer-model registry.
  351. const compatIndex = useMemo<PrinterCompatibilityIndex>(
  352. () => buildCompatibilityIndex(printerModelsQuery.data ?? {}),
  353. [printerModelsQuery.data],
  354. );
  355. // Printer / process preset names the source 3MF was prepared with. The
  356. // plates query resolves before the presets query (the latter is gated on
  357. // it), so these are known by the time the pre-pick effects run.
  358. const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
  359. const embeddedProcess = platesQuery.data?.embedded_process ?? null;
  360. // Printer pre-pick: defaults to the printer the 3MF was prepared for when
  361. // that preset is available, else the first listed printer. Runs once when
  362. // presets first arrive; later re-renders preserve any manual choice.
  363. useEffect(() => {
  364. const data = presetsQuery.data;
  365. if (!data) return;
  366. if (printerPreset == null) {
  367. setPrinterPreset(
  368. findPresetByName(data, 'printer', embeddedPrinter) ?? pickDefault(data, 'printer'),
  369. );
  370. }
  371. // eslint-disable-next-line react-hooks/exhaustive-deps
  372. }, [presetsQuery.data, embeddedPrinter]);
  373. // Process pre-pick / re-pick (#1325): defaults to a process compatible with
  374. // the selected printer, and re-defaults when a printer change leaves the
  375. // current process incompatible. A compatible or unknown manual pick is kept.
  376. useEffect(() => {
  377. const data = presetsQuery.data;
  378. if (!data) return;
  379. setProcessPreset((current) => {
  380. if (current) {
  381. const p = findPreset(data, current, 'process');
  382. if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
  383. return current;
  384. }
  385. }
  386. return pickProcessDefault(data, selectedPrinterName, compatIndex, embeddedProcess);
  387. });
  388. }, [presetsQuery.data, selectedPrinterName, compatIndex, embeddedProcess]);
  389. // Filament pre-pick: re-runs when the active filament-slot count changes
  390. // (plate selection, single-plate metadata arriving) or the selected printer
  391. // changes. Each slot scores every available filament preset against the
  392. // slot's required (type, colour); an existing pick (incl. a user override)
  393. // is kept as long as it's still compatible with the selected printer, while
  394. // null slots and printer-incompatible picks are re-picked (#1325).
  395. useEffect(() => {
  396. const data = presetsQuery.data;
  397. if (!data) return;
  398. setFilamentPresets((current) => {
  399. return filamentSlots.map((slot, i) => {
  400. const cur = current[i] ?? null;
  401. if (cur) {
  402. const p = findPreset(data, cur, 'filament');
  403. if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
  404. return cur;
  405. }
  406. }
  407. return pickFilamentForSlot(
  408. data,
  409. { type: slot.type, color: slot.color },
  410. selectedPrinterName,
  411. compatIndex,
  412. );
  413. });
  414. });
  415. }, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
  416. const enqueueMutation = useMutation({
  417. mutationFn: async (plate: number | null) => {
  418. const body = buildSliceBody(plate);
  419. if (source.kind === 'libraryFile') {
  420. return api.sliceLibraryFile(source.id, body);
  421. }
  422. return api.sliceArchive(source.id, body);
  423. },
  424. onSuccess: (enqueue) => {
  425. trackJob(enqueue.job_id, source.kind, source.filename);
  426. onClose();
  427. },
  428. onError: (err: unknown) => {
  429. const msg = err instanceof Error ? err.message : String(err);
  430. setErrorMessage(msg);
  431. },
  432. });
  433. // Body builder shared by the single-plate and slice-all paths. ``plate``
  434. // is the 1-indexed plate number to slice, or ``null`` for STL / single-
  435. // plate 3MF sources where the field is omitted entirely.
  436. function buildSliceBody(plate: number | null): SliceRequest {
  437. if (
  438. !printerPreset ||
  439. !processPreset ||
  440. filamentPresets.length === 0 ||
  441. filamentPresets.some((r) => r == null)
  442. ) {
  443. throw new Error(t('slice.allPresetsRequired'));
  444. }
  445. return {
  446. printer_preset: printerPreset,
  447. process_preset: processPreset,
  448. filament_preset: filamentPresets[0] as PresetRef,
  449. filament_presets: filamentPresets as PresetRef[],
  450. ...(plate != null ? { plate } : {}),
  451. ...(bedType != null ? { bed_type: bedType } : {}),
  452. };
  453. }
  454. // Slice button stays disabled until the preview slice / embedded-metadata
  455. // read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
  456. // has a picked profile.
  457. const isReady =
  458. printerPreset != null &&
  459. processPreset != null &&
  460. filamentReqsQuery.isSuccess &&
  461. filamentPresets.length > 0 &&
  462. filamentPresets.every((r) => r != null);
  463. const isEnqueuing = enqueueMutation.isPending;
  464. const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
  465. const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;
  466. // Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
  467. // entire flow (matches the existing PlatePickerModal contract used by the
  468. // archive g-code-viewer entry point).
  469. if (needsPlatePicker && platesQuery.data) {
  470. return (
  471. <PlatePickerModal
  472. plates={platesQuery.data.plates}
  473. onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
  474. onClose={onClose}
  475. />
  476. );
  477. }
  478. // Step 2 (or only step for single-plate / non-3MF / load-failure): preset
  479. // picker. While the plates query is in-flight we still render the shell
  480. // because the presets query is gated on it; the loader covers both.
  481. return (
  482. <div
  483. className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
  484. onClick={() => {
  485. if (!isEnqueuing) onClose();
  486. }}
  487. >
  488. <div
  489. className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
  490. onClick={(e) => e.stopPropagation()}
  491. >
  492. {/* Header */}
  493. <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">
  494. <div className="min-w-0">
  495. <h3 className="text-white font-medium flex items-center gap-2">
  496. <Cog className="w-4 h-4" />
  497. {t('slice.title')}
  498. </h3>
  499. <p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
  500. {source.filename}
  501. {selectedPlate != null
  502. ? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
  503. : ''}
  504. </p>
  505. </div>
  506. <button
  507. onClick={onClose}
  508. disabled={isEnqueuing}
  509. className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
  510. aria-label={t('common.close')}
  511. >
  512. <X className="w-5 h-5" />
  513. </button>
  514. </div>
  515. {/* Body */}
  516. <div className="flex-1 overflow-y-auto p-4 space-y-4">
  517. {/* Preset listing loader — printer/process dropdowns can't render
  518. without it. Plate query reuses the same spinner since it's
  519. also blocking. */}
  520. {(platesQuery.isLoading || presetsQuery.isLoading) && (
  521. <div className="flex items-center gap-2 text-bambu-gray text-sm">
  522. <Loader2 className="w-4 h-4 animate-spin" />
  523. {t('slice.loadingPresets')}
  524. </div>
  525. )}
  526. {presetsQuery.isError && (
  527. <div className="text-sm text-red-700 dark:text-red-400" role="alert">
  528. {t(
  529. 'slice.presetsLoadFailed',
  530. 'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.',
  531. )}
  532. </div>
  533. )}
  534. {presetsQuery.data && (
  535. <>
  536. <div className="flex items-start justify-between gap-2">
  537. <div className="flex-1 space-y-2">
  538. <CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
  539. <CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
  540. </div>
  541. <button
  542. type="button"
  543. onClick={handleRefreshPresets}
  544. disabled={isRefreshing || isEnqueuing}
  545. className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
  546. title={t('slice.refreshPresetsTitle')}
  547. aria-label={t('slice.refreshPresets')}
  548. >
  549. <RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
  550. {t('slice.refreshPresets')}
  551. </button>
  552. </div>
  553. {/* CloudStatusBanner above is hidden via flex-1 wrapper when
  554. status === 'ok' (returns null in that case), but the Refresh
  555. button stays visible regardless so users can pick up cloud /
  556. bundled changes even when sign-in is healthy. */}
  557. {/* Slicer Pipelines (#1425): apply a saved preset bundle to all
  558. four slots, or save the current selection as a pipeline.
  559. Pipelines are managed in Settings → Workflow → Pipelines. */}
  560. <div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
  561. <span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
  562. <Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
  563. </span>
  564. <select
  565. value=""
  566. disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
  567. onChange={(e) => {
  568. const id = parseInt(e.target.value, 10);
  569. if (Number.isNaN(id)) return;
  570. const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
  571. if (!picked) return;
  572. // Apply slot state. The filament list is right-padded from
  573. // current state so a pipeline with fewer entries than the
  574. // current source's slot count keeps the existing tail.
  575. setPrinterPreset(picked.printer_preset);
  576. setProcessPreset(picked.process_preset);
  577. setBedType(picked.bed_type);
  578. setFilamentPresets((current) => {
  579. const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
  580. for (let i = 0; i < next.length; i++) {
  581. if (i < picked.filament_presets.length) {
  582. next[i] = picked.filament_presets[i];
  583. }
  584. }
  585. return next;
  586. });
  587. showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
  588. // Reset the dropdown so the user can re-apply the same
  589. // pipeline if needed (selects don't fire onChange when
  590. // value reselects the same option).
  591. e.target.value = '';
  592. }}
  593. className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white disabled:opacity-50 disabled:cursor-not-allowed flex-1 min-w-[10ch]"
  594. aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
  595. >
  596. <option value="">
  597. {(pipelinesQuery.data?.pipelines.length ?? 0) === 0
  598. ? t('slice.pipelines.empty', 'No saved pipelines')
  599. : t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
  600. </option>
  601. {pipelinesQuery.data?.pipelines.map((p) => (
  602. <option key={p.id} value={p.id}>
  603. {p.name}
  604. </option>
  605. ))}
  606. </select>
  607. {!savePipelineOpen ? (
  608. <button
  609. type="button"
  610. onClick={() => {
  611. setPipelineDraftName('');
  612. setSavePipelineOpen(true);
  613. }}
  614. disabled={
  615. isEnqueuing ||
  616. !printerPreset ||
  617. !processPreset ||
  618. filamentPresets.length === 0 ||
  619. filamentPresets.some((f) => f === null)
  620. }
  621. className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
  622. title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
  623. >
  624. {t('slice.pipelines.saveButton', 'Save as pipeline')}
  625. </button>
  626. ) : (
  627. <div className="flex items-center gap-1 flex-1 min-w-[16ch]">
  628. <input
  629. autoFocus
  630. value={pipelineDraftName}
  631. onChange={(e) => setPipelineDraftName(e.target.value)}
  632. placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
  633. aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
  634. className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
  635. />
  636. <button
  637. type="button"
  638. onClick={() => {
  639. const trimmed = pipelineDraftName.trim();
  640. if (!trimmed || !printerPreset || !processPreset) return;
  641. const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
  642. if (nonNull.length === 0) return;
  643. createPipelineMutation.mutate({
  644. name: trimmed,
  645. printer_preset: printerPreset,
  646. process_preset: processPreset,
  647. filament_presets: nonNull,
  648. bed_type: bedType,
  649. });
  650. }}
  651. disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
  652. className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
  653. >
  654. {createPipelineMutation.isPending ? (
  655. <Loader2 className="w-3 h-3 animate-spin" />
  656. ) : (
  657. t('common.save', 'Save')
  658. )}
  659. </button>
  660. <button
  661. type="button"
  662. onClick={() => {
  663. setSavePipelineOpen(false);
  664. setPipelineDraftName('');
  665. }}
  666. className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
  667. >
  668. {t('common.cancel', 'Cancel')}
  669. </button>
  670. </div>
  671. )}
  672. </div>
  673. <PresetDropdown
  674. label={t('slice.printer')}
  675. slot="printer"
  676. data={presetsQuery.data}
  677. value={printerPreset}
  678. onChange={setPrinterPreset}
  679. disabled={isEnqueuing}
  680. />
  681. <PresetDropdown
  682. label={t('slice.process')}
  683. slot="process"
  684. data={presetsQuery.data}
  685. value={processPreset}
  686. onChange={setProcessPreset}
  687. disabled={isEnqueuing}
  688. selectedPrinterName={selectedPrinterName}
  689. compatIndex={compatIndex}
  690. />
  691. {/* Bed-type override (#1337). Always visible, always enabled.
  692. The backend patches curr_bed_type on the resolved process
  693. JSON before forwarding to the sidecar. */}
  694. <BedTypeDropdown
  695. value={bedType}
  696. onChange={setBedType}
  697. disabled={isEnqueuing}
  698. />
  699. {/* Filament reqs may need a server-side preview-slice for
  700. unsliced project files (single-pass, then cached). Show a
  701. scoped spinner so the user sees the printer/process
  702. dropdowns instead of an opaque "Loading presets…" wait. */}
  703. {filamentReqsQuery.isLoading ? (
  704. <FilamentAnalysisSpinner
  705. requestId={previewRequestId}
  706. sourceName={source.filename}
  707. />
  708. ) : (
  709. filamentSlots.map((slot, idx) => {
  710. // Slots flagged by the backend as not used by the
  711. // picked plate are auto-picked from project metadata
  712. // and disabled — the slicer CLI still needs a
  713. // profile per project slot, but the user shouldn't
  714. // have to think about slots their plate doesn't
  715. // paint with. used_in_plate defaults to true when
  716. // missing (sliced 3MFs and the no-flag legacy path).
  717. const isUsed = slot.used_in_plate !== false;
  718. const baseLabel =
  719. filamentSlots.length > 1
  720. ? t('slice.filamentSlot', {
  721. index: idx + 1,
  722. type: slot.type,
  723. })
  724. : t('slice.filament');
  725. const label = isUsed
  726. ? baseLabel
  727. : `${baseLabel} ${t('slice.notUsedByPlate')}`;
  728. return (
  729. <PresetDropdown
  730. key={`filament-${idx}`}
  731. label={label}
  732. slot="filament"
  733. data={presetsQuery.data}
  734. value={filamentPresets[idx] ?? null}
  735. onChange={(ref) =>
  736. setFilamentPresets((current) => {
  737. const next = current.length === filamentSlots.length
  738. ? [...current]
  739. : filamentSlots.map((_, i) => current[i] ?? null);
  740. next[idx] = ref;
  741. return next;
  742. })
  743. }
  744. disabled={isEnqueuing || !isUsed}
  745. swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
  746. selectedPrinterName={selectedPrinterName}
  747. compatIndex={compatIndex}
  748. />
  749. );
  750. })
  751. )}
  752. </>
  753. )}
  754. {errorMessage && (
  755. <div className="text-sm text-red-700 dark:text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
  756. {errorMessage}
  757. </div>
  758. )}
  759. </div>
  760. {/* Footer */}
  761. <div className="flex-shrink-0 flex justify-end gap-2 px-4 py-3 border-t border-bambu-dark-tertiary/40">
  762. <button
  763. type="button"
  764. onClick={onClose}
  765. disabled={isEnqueuing}
  766. className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
  767. >
  768. {t('common.cancel')}
  769. </button>
  770. {canSliceAll && (
  771. <label
  772. className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
  773. title={t('slice.actionAllTitle', { count: totalPlateCount })}
  774. >
  775. <input
  776. type="checkbox"
  777. checked={sliceAllPlates}
  778. onChange={(e) => setSliceAllPlates(e.target.checked)}
  779. disabled={isEnqueuing}
  780. className="cursor-pointer"
  781. />
  782. {t('slice.allPlatesToggle', { count: totalPlateCount })}
  783. </label>
  784. )}
  785. <button
  786. type="button"
  787. onClick={() => {
  788. setErrorMessage(null);
  789. // ``plate=0`` is the sidecar's "all plates" sentinel — passes
  790. // ``--slice 0`` to the BS CLI which produces a single 3MF
  791. // with one ``Metadata/plate_N.gcode`` entry per plate.
  792. const platePayload = sliceAllPlates ? 0 : selectedPlate;
  793. enqueueMutation.mutate(platePayload);
  794. }}
  795. disabled={!isReady || isEnqueuing}
  796. className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
  797. >
  798. {isEnqueuing ? (
  799. <>
  800. <Loader2 className="w-4 h-4 animate-spin" />
  801. {t('slice.enqueuing')}
  802. </>
  803. ) : sliceAllPlates ? (
  804. t('slice.actionAll', { count: totalPlateCount })
  805. ) : (
  806. t('slice.action')
  807. )}
  808. </button>
  809. </div>
  810. </div>
  811. </div>
  812. );
  813. }
  814. function CloudStatusBanner({
  815. status,
  816. cloudName = 'bambu',
  817. }: {
  818. status: SlicerCloudStatus;
  819. cloudName?: 'bambu' | 'orca';
  820. }) {
  821. const { t } = useTranslation();
  822. // `ok` is the happy path. `not_authenticated` is silenced too: a user who
  823. // hasn't signed in (or has explicitly logged out — #1712) doesn't need a
  824. // permanent nag at the top of the modal; sign-in lives on the Profiles
  825. // page if they want it. Only `expired` and `unreachable` surface — those
  826. // are real breakage states a previously-signed-in user needs to see.
  827. if (status === 'ok' || status === 'not_authenticated') return null;
  828. // Same status vocabulary for both Bambu and Orca Cloud — only the
  829. // user-facing text varies. The fallbacks below name each cloud explicitly
  830. // so the banner makes sense without translation when i18n hasn't been
  831. // updated for a new locale.
  832. const messages =
  833. cloudName === 'orca'
  834. ? {
  835. expired: {
  836. key: 'slice.orcaCloud.expired',
  837. fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
  838. },
  839. unreachable: {
  840. key: 'slice.orcaCloud.unreachable',
  841. fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
  842. },
  843. }
  844. : {
  845. expired: {
  846. key: 'slice.cloud.expired',
  847. fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
  848. },
  849. unreachable: {
  850. key: 'slice.cloud.unreachable',
  851. fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
  852. },
  853. };
  854. const tones: Record<'expired' | 'unreachable', { tone: string; icon: typeof Cloud }> = {
  855. expired: {
  856. tone: 'border-amber-300 dark:border-amber-700/40 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200',
  857. icon: CloudOff,
  858. },
  859. unreachable: {
  860. tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
  861. icon: CloudOff,
  862. },
  863. };
  864. const { tone, icon: Icon } = tones[status];
  865. const { key, fallback } = messages[status];
  866. return (
  867. <div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
  868. <Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
  869. <span>{t(key, fallback)}</span>
  870. </div>
  871. );
  872. }
  873. // Build-plate options offered in the SliceModal (#1337). Values are the
  874. // canonical strings the slicer's StaticPrintConfig validator accepts as
  875. // `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
  876. // enum; OrcaSlicer accepts the same set with a Supertack alias that users
  877. // can target via the same dropdown if they re-import their presets.
  878. const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
  879. { value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
  880. {
  881. value: 'Cool Plate (SuperTack)',
  882. labelKey: 'slice.bedType.coolPlateSuperTack',
  883. fallback: 'Cool Plate SuperTack',
  884. },
  885. { value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
  886. { value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
  887. { value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
  888. { value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
  889. ];
  890. function BedTypeDropdown({
  891. value,
  892. onChange,
  893. disabled,
  894. }: {
  895. value: string | null;
  896. onChange: (value: string | null) => void;
  897. disabled?: boolean;
  898. }) {
  899. const { t } = useTranslation();
  900. return (
  901. <label className="block">
  902. <span className="block text-xs text-bambu-gray mb-1">
  903. {t('slice.bedType.label')}
  904. </span>
  905. <select
  906. value={value ?? ''}
  907. onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
  908. disabled={disabled}
  909. className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
  910. >
  911. <option value="">{t('slice.bedType.auto')}</option>
  912. {BED_TYPE_OPTIONS.map((opt) => (
  913. <option key={opt.value} value={opt.value}>
  914. {t(opt.labelKey, opt.fallback)}
  915. </option>
  916. ))}
  917. </select>
  918. </label>
  919. );
  920. }
  921. interface PresetDropdownProps {
  922. label: string;
  923. slot: Slot;
  924. data: UnifiedPresetsResponse;
  925. value: PresetRef | null;
  926. onChange: (ref: PresetRef | null) => void;
  927. disabled?: boolean;
  928. // Optional colour swatch shown next to the label — used for multi-color
  929. // filament slots so the user can see at a glance which slot they're
  930. // configuring against the source 3MF's per-slot colour.
  931. swatchColor?: string;
  932. // Selected printer context (#1325). When provided for a process / filament
  933. // slot, presets that resolve to a different printer (per compatIndex) move
  934. // into a trailing "Other printers" group instead of the main tier list.
  935. selectedPrinterName?: string | null;
  936. compatIndex?: PrinterCompatibilityIndex;
  937. }
  938. function PresetDropdown({
  939. label,
  940. slot,
  941. data,
  942. value,
  943. onChange,
  944. disabled,
  945. swatchColor,
  946. selectedPrinterName,
  947. compatIndex,
  948. }: PresetDropdownProps) {
  949. const { t } = useTranslation();
  950. // Tier sections (imported → cloud → standard), plus — for a process /
  951. // filament slot with a selected printer — a trailing group of presets that
  952. // resolve to a different printer (#1325). Compatibility-unknown presets
  953. // stay in their tier, so a custom / untagged preset is never hidden, and
  954. // empty sections collapse out.
  955. const { sections, otherEntries } = useMemo(() => {
  956. const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
  957. { key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
  958. { key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
  959. { key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
  960. { key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
  961. ];
  962. const filterByPrinter = slot !== 'printer';
  963. const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
  964. const other: UnifiedPreset[] = [];
  965. for (const { key, label: lk, fallback } of tiers) {
  966. const entries = (data[key] as UnifiedPresetsBySlot)[slot];
  967. if (!filterByPrinter) {
  968. if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
  969. continue;
  970. }
  971. const compatible: UnifiedPreset[] = [];
  972. for (const p of entries) {
  973. if (
  974. presetCompatibility(
  975. p,
  976. // filterByPrinter is true here, so slot is never 'printer'.
  977. slot as 'process' | 'filament',
  978. selectedPrinterName ?? null,
  979. compatIndex ?? EMPTY_COMPATIBILITY_INDEX,
  980. ) === 'mismatch'
  981. ) {
  982. other.push(p);
  983. } else {
  984. compatible.push(p);
  985. }
  986. }
  987. if (compatible.length > 0) {
  988. compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
  989. }
  990. }
  991. return { sections: compatSections, otherEntries: other };
  992. }, [data, slot, t, selectedPrinterName, compatIndex]);
  993. const totalEntries =
  994. sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
  995. return (
  996. <label className="block">
  997. <span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
  998. {swatchColor && (
  999. <span
  1000. className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
  1001. style={{ backgroundColor: swatchColor || 'transparent' }}
  1002. aria-hidden
  1003. />
  1004. )}
  1005. <span>{label}</span>
  1006. </span>
  1007. <select
  1008. value={toRefValue(value)}
  1009. onChange={(e) => onChange(fromRefValue(e.target.value))}
  1010. disabled={disabled || totalEntries === 0}
  1011. className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
  1012. >
  1013. <option value="">
  1014. {totalEntries === 0
  1015. ? t('slice.noPresetsForSlot')
  1016. : t('slice.selectPreset')}
  1017. </option>
  1018. {sections.map((section) => (
  1019. <optgroup key={section.tierLabel} label={section.tierLabel}>
  1020. {section.entries.map((p) => (
  1021. <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
  1022. {p.name}
  1023. </option>
  1024. ))}
  1025. </optgroup>
  1026. ))}
  1027. {otherEntries.length > 0 && (
  1028. <optgroup label={t('slice.otherPrinters')}>
  1029. {otherEntries.map((p) => (
  1030. <option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
  1031. {p.name}
  1032. </option>
  1033. ))}
  1034. </optgroup>
  1035. )}
  1036. </select>
  1037. </label>
  1038. );
  1039. }