PlateSelector.tsx 6.7 KB

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