PlateSelector.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import { useEffect, useRef } from 'react';
  2. import { Layers, Check, AlertTriangle, Square, CheckSquare } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import type { PlateSelectorProps } from './types';
  5. import { formatDuration } from '../../utils/date';
  6. import { withMediaToken } from '../../api/client';
  7. import { getBedTypeInfo } from '../../utils/bedType';
  8. import { NumberInput } from '../NumberInput';
  9. /**
  10. * Plate selection grid for multi-plate 3MF files.
  11. * Shows thumbnails, names, objects, and print times for each plate.
  12. * In multi-select mode, plates have checkboxes for selecting a subset.
  13. * In single-select mode, only one plate can be selected at a time.
  14. */
  15. export function PlateSelector({
  16. plates,
  17. isMultiPlate,
  18. selectedPlates,
  19. onToggle,
  20. onSelectAll,
  21. onDeselectAll,
  22. multiSelect,
  23. quantities,
  24. onQuantityChange,
  25. }: PlateSelectorProps) {
  26. const { t } = useTranslation();
  27. // A plate selected on opening (a reprint of plate 10, say) can be far down a
  28. // long list: bring it into view once, when the cards first appear (#3395).
  29. const firstSelectedRef = useRef<HTMLDivElement | null>(null);
  30. const scrolled = useRef(false);
  31. const visible = isMultiPlate && plates.length > 1;
  32. useEffect(() => {
  33. if (scrolled.current || !visible || !firstSelectedRef.current) return;
  34. scrolled.current = true;
  35. firstSelectedRef.current.scrollIntoView?.({ block: 'nearest' });
  36. }, [visible, selectedPlates]);
  37. const firstSelected = plates.find((p) => selectedPlates.has(p.index))?.index;
  38. // Only show for multi-plate files with multiple plates
  39. if (!isMultiPlate || plates.length <= 1) {
  40. return null;
  41. }
  42. const allSelected = selectedPlates.size === plates.length;
  43. const showQuantities = !!quantities && !!onQuantityChange;
  44. const totalRuns = showQuantities
  45. ? plates.reduce((sum, p) => (selectedPlates.has(p.index) ? sum + (quantities[p.index] ?? 1) : sum), 0)
  46. : 0;
  47. return (
  48. <div className="mb-4">
  49. <div className="flex items-center gap-2 mb-2">
  50. <Layers className="w-4 h-4 text-bambu-gray" />
  51. <span className="text-sm text-bambu-gray">Select Plate{multiSelect ? 's' : ''} to Print</span>
  52. {selectedPlates.size === 0 && (
  53. <span className="text-xs text-orange-700 dark:text-orange-400 flex items-center gap-1">
  54. <AlertTriangle className="w-3 h-3" />
  55. Selection required
  56. </span>
  57. )}
  58. {multiSelect && onSelectAll && onDeselectAll && (
  59. <button
  60. type="button"
  61. onClick={allSelected ? onDeselectAll : onSelectAll}
  62. className={`ml-auto text-xs px-2 py-0.5 rounded-full border transition-colors ${
  63. allSelected
  64. ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
  65. : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
  66. }`}
  67. >
  68. {allSelected
  69. ? t('queue.deselectAll')
  70. : t('queue.selectAllPlates', { count: plates.length })}
  71. </button>
  72. )}
  73. </div>
  74. <div className="grid grid-cols-2 gap-2">
  75. {plates.map((plate) => {
  76. const isSelected = selectedPlates.has(plate.index);
  77. return (
  78. /* The quantity stepper can't live inside the selection button —
  79. nesting an input in a button is invalid and every keystroke
  80. would toggle the plate. The card is a div; the selectable
  81. region stays a button beside the stepper. */
  82. <div
  83. key={plate.index}
  84. ref={plate.index === firstSelected ? firstSelectedRef : undefined}
  85. className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
  86. isSelected
  87. ? 'border-bambu-green bg-bambu-green/10'
  88. : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
  89. }`}
  90. >
  91. <button
  92. type="button"
  93. onClick={() => onToggle(plate.index)}
  94. className="flex items-center gap-2 text-left min-w-0 flex-1"
  95. >
  96. {multiSelect && (
  97. isSelected
  98. ? <CheckSquare className="w-4 h-4 text-bambu-green flex-shrink-0" />
  99. : <Square className="w-4 h-4 text-bambu-gray flex-shrink-0" />
  100. )}
  101. {plate.has_thumbnail && plate.thumbnail_url != null ? (
  102. <img
  103. src={withMediaToken(plate.thumbnail_url)}
  104. alt={`Plate ${plate.index}`}
  105. className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
  106. />
  107. ) : (
  108. <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
  109. <Layers className="w-5 h-5 text-bambu-gray" />
  110. </div>
  111. )}
  112. <div className="min-w-0 flex-1">
  113. <p className="text-sm text-white font-medium truncate">
  114. {plate.name || `Plate ${plate.index}`}
  115. </p>
  116. <p className="text-xs text-bambu-gray truncate">
  117. {plate.objects.length > 0
  118. ? plate.objects.slice(0, 3).join(', ') +
  119. (plate.objects.length > 3 ? '...' : '')
  120. : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
  121. {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}
  122. </p>
  123. {(() => {
  124. // Per-plate bed-type badge so multi-plate prints make the
  125. // required plate explicit at scheduling time (#1281).
  126. const bed = getBedTypeInfo(plate.bed_type);
  127. if (!bed) return null;
  128. return (
  129. <p className="text-xs text-bambu-gray flex items-center gap-1 mt-0.5 truncate" title={bed.label}>
  130. <img src={bed.icon} alt="" className="w-3.5 h-3.5 object-contain flex-shrink-0" />
  131. <span className="truncate">{bed.label}</span>
  132. </p>
  133. );
  134. })()}
  135. </div>
  136. {!multiSelect && isSelected && (
  137. <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
  138. )}
  139. </button>
  140. {showQuantities && isSelected && (
  141. <div className="flex items-center gap-1 flex-shrink-0">
  142. <span className="text-xs text-bambu-gray" aria-hidden="true">×</span>
  143. <NumberInput
  144. min={1}
  145. max={999}
  146. value={quantities[plate.index] ?? 1}
  147. aria-label={t('queue.plateQuantityLabel', {
  148. plate: plate.name || t('queue.plateNumber', { index: plate.index }),
  149. })}
  150. onChange={(v) => onQuantityChange(plate.index, v)}
  151. fallback={1}
  152. className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
  153. />
  154. </div>
  155. )}
  156. </div>
  157. );
  158. })}
  159. </div>
  160. {showQuantities && totalRuns > selectedPlates.size && (
  161. <p className="text-xs text-bambu-gray mt-2">
  162. {t('queue.plateQuantityTotal', { count: totalRuns })}
  163. </p>
  164. )}
  165. </div>
  166. );
  167. }