FeedDirectionModal.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import { useEffect, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Loader2 } from 'lucide-react';
  4. import { Card, CardContent } from './Card';
  5. import { Button } from './Button';
  6. import type { ExtruderSlot } from '../api/client';
  7. // Hotend ids as the firmware numbers them. Mirrors BambuStudio's
  8. // MAIN_EXTRUDER_ID / DEPUTY_EXTRUDER_ID and `fts_routing.py` on the backend.
  9. const RIGHT_EXTRUDER = 0;
  10. const LEFT_EXTRUDER = 1;
  11. interface FeedDirectionModalProps {
  12. // Human-readable name of the slot being loaded, e.g. "AMS-A 3".
  13. slotLabel: string;
  14. // The slot's own coordinates, used to spot the hotend already holding it.
  15. amsId: number;
  16. slotId: number;
  17. extruderSlots: Record<string, ExtruderSlot>;
  18. isLoading?: boolean;
  19. onConfirm: (extruderId: number) => void;
  20. onCancel: () => void;
  21. }
  22. /**
  23. * Asks which hotend to feed a slot into, for printers with a Filament Track
  24. * Switch fitted.
  25. *
  26. * Without a switch each AMS is wired to one hotend and the firmware works the
  27. * target out for itself, so the load command carries no hotend at all. With one
  28. * fitted, every AMS is bound to a switch *inlet* instead and either hotend is
  29. * reachable — the firmware then has nothing to infer from and drops a command
  30. * that does not name one. BambuStudio asks the same question in the same place
  31. * (`FeedDirectionDialog`), including leaving Confirm disabled until a side is
  32. * picked, so there is no default to accidentally act on.
  33. */
  34. export function FeedDirectionModal({
  35. slotLabel,
  36. amsId,
  37. slotId,
  38. extruderSlots,
  39. isLoading = false,
  40. onConfirm,
  41. onCancel,
  42. }: FeedDirectionModalProps) {
  43. const { t } = useTranslation();
  44. const [selected, setSelected] = useState<number | null>(null);
  45. useEffect(() => {
  46. const handleKeyDown = (e: KeyboardEvent) => {
  47. if (e.key === 'Escape' && !isLoading) onCancel();
  48. };
  49. window.addEventListener('keydown', handleKeyDown);
  50. return () => window.removeEventListener('keydown', handleKeyDown);
  51. }, [onCancel, isLoading]);
  52. // A hotend already fed from this exact slot cannot be loaded from it again.
  53. const holdsThisSlot = (extruderId: number) => {
  54. const slot = extruderSlots[String(extruderId)];
  55. return slot?.ams_id === amsId && slot?.slot_id === slotId;
  56. };
  57. const options = [
  58. { extruderId: LEFT_EXTRUDER, label: t('printers.ams.feedLeft'), taken: holdsThisSlot(LEFT_EXTRUDER) },
  59. { extruderId: RIGHT_EXTRUDER, label: t('printers.ams.feedRight'), taken: holdsThisSlot(RIGHT_EXTRUDER) },
  60. ];
  61. const selectedIsTaken = options.some(o => o.extruderId === selected && o.taken);
  62. // Status keeps arriving while the dialog is open, so a hotend can become the
  63. // one holding this slot after it was picked — someone loading it from the
  64. // printer's own screen. Drop the selection rather than leave Confirm armed on
  65. // an option that has since been disabled.
  66. useEffect(() => {
  67. if (selectedIsTaken) setSelected(null);
  68. }, [selectedIsTaken]);
  69. return (
  70. <div
  71. className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
  72. onClick={isLoading ? undefined : onCancel}
  73. >
  74. <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
  75. <CardContent className="p-6">
  76. <h3 className="text-lg font-semibold text-white mb-2">
  77. {t('printers.ams.feedTitle', { slot: slotLabel })}
  78. </h3>
  79. <p className="text-bambu-gray text-sm">{t('printers.ams.feedPrompt')}</p>
  80. <div className="grid grid-cols-2 gap-3 mt-4">
  81. {options.map(({ extruderId, label, taken }) => {
  82. const isSelected = selected === extruderId;
  83. return (
  84. <button
  85. key={extruderId}
  86. type="button"
  87. onClick={() => setSelected(extruderId)}
  88. disabled={taken || isLoading}
  89. title={taken ? t('printers.ams.feedAlreadyLoaded') : undefined}
  90. className={`p-3 rounded-lg border text-sm transition-colors ${
  91. taken
  92. ? 'border-transparent bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
  93. : isSelected
  94. ? 'border-bambu-green/50 bg-bambu-green/10 text-white'
  95. : 'border-transparent bg-bambu-dark text-white hover:bg-bambu-dark-tertiary'
  96. }`}
  97. >
  98. <div className="font-medium">{label}</div>
  99. {taken && (
  100. <div className="text-xs mt-1">{t('printers.ams.feedAlreadyLoaded')}</div>
  101. )}
  102. </button>
  103. );
  104. })}
  105. </div>
  106. <div className="flex gap-3 mt-6">
  107. <Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
  108. {t('common.cancel')}
  109. </Button>
  110. <Button
  111. onClick={() => selected !== null && onConfirm(selected)}
  112. className="flex-1"
  113. disabled={selected === null || isLoading}
  114. >
  115. {isLoading ? (
  116. <>
  117. <Loader2 className="w-4 h-4 mr-2 animate-spin" />
  118. {t('common.loading')}
  119. </>
  120. ) : (
  121. t('common.confirm')
  122. )}
  123. </Button>
  124. </div>
  125. </CardContent>
  126. </Card>
  127. </div>
  128. );
  129. }