LinkSpoolModal.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Loader2, Link2, Check } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { Button } from './Button';
  7. import { useToast } from '../contexts/ToastContext';
  8. interface LinkSpoolModalProps {
  9. isOpen: boolean;
  10. onClose: () => void;
  11. trayUuid: string;
  12. trayInfo?: {
  13. type: string;
  14. color: string;
  15. location: string;
  16. };
  17. }
  18. export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
  19. const { t } = useTranslation();
  20. const queryClient = useQueryClient();
  21. const { showToast } = useToast();
  22. const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
  23. // Fetch unlinked spools
  24. const { data: unlinkedSpools, isLoading } = useQuery({
  25. queryKey: ['unlinked-spools'],
  26. queryFn: api.getUnlinkedSpools,
  27. enabled: isOpen,
  28. });
  29. // Link mutation
  30. const linkMutation = useMutation({
  31. mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
  32. onSuccess: () => {
  33. queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
  34. queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
  35. queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
  36. showToast(t('spoolman.linkSuccess'), 'success');
  37. onClose();
  38. },
  39. onError: (error: Error) => {
  40. showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
  41. },
  42. });
  43. if (!isOpen) return null;
  44. const handleLink = () => {
  45. if (selectedSpoolId) {
  46. linkMutation.mutate(selectedSpoolId);
  47. }
  48. };
  49. return (
  50. <div className="fixed inset-0 z-50 flex items-center justify-center">
  51. {/* Backdrop */}
  52. <div
  53. className="absolute inset-0 bg-black/60 backdrop-blur-sm"
  54. onClick={onClose}
  55. />
  56. {/* Modal */}
  57. <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
  58. {/* Header */}
  59. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  60. <div className="flex items-center gap-2">
  61. <Link2 className="w-5 h-5 text-bambu-green" />
  62. <h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
  63. </div>
  64. <button
  65. onClick={onClose}
  66. className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
  67. >
  68. <X className="w-5 h-5" />
  69. </button>
  70. </div>
  71. {/* Content */}
  72. <div className="p-4 space-y-4">
  73. {/* Tray info */}
  74. {trayInfo && (
  75. <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  76. <p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
  77. <div className="flex items-center gap-2">
  78. {trayInfo.color && (
  79. <span
  80. className="w-4 h-4 rounded-full border border-white/20"
  81. style={{ backgroundColor: `#${trayInfo.color}` }}
  82. />
  83. )}
  84. <span className="text-white font-medium">{trayInfo.type}</span>
  85. <span className="text-bambu-gray">({trayInfo.location})</span>
  86. </div>
  87. </div>
  88. )}
  89. {/* Spool UUID */}
  90. <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  91. <p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
  92. <code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
  93. </div>
  94. {/* Spool list */}
  95. <div>
  96. <p className="text-sm text-bambu-gray mb-2">
  97. {t('spoolman.selectSpool')}:
  98. </p>
  99. {isLoading ? (
  100. <div className="flex justify-center py-8">
  101. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  102. </div>
  103. ) : unlinkedSpools && unlinkedSpools.length > 0 ? (
  104. <div className="max-h-64 overflow-y-auto space-y-2">
  105. {unlinkedSpools.map((spool) => (
  106. <button
  107. key={spool.id}
  108. onClick={() => setSelectedSpoolId(spool.id)}
  109. className={`w-full p-3 rounded-lg border text-left transition-colors ${
  110. selectedSpoolId === spool.id
  111. ? 'bg-bambu-green/20 border-bambu-green'
  112. : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
  113. }`}
  114. >
  115. <div className="flex items-center gap-2">
  116. {spool.filament_color_hex && (
  117. <span
  118. className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
  119. style={{ backgroundColor: `#${spool.filament_color_hex}` }}
  120. />
  121. )}
  122. <div className="flex-1 min-w-0">
  123. <p className="text-white font-medium truncate">
  124. {spool.filament_name || 'Unknown filament'}
  125. </p>
  126. <p className="text-xs text-bambu-gray">
  127. {spool.filament_material || 'Unknown'}
  128. {spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
  129. {spool.location && ` - ${spool.location}`}
  130. </p>
  131. </div>
  132. {selectedSpoolId === spool.id && (
  133. <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
  134. )}
  135. </div>
  136. </button>
  137. ))}
  138. </div>
  139. ) : (
  140. <div className="text-center py-8 text-bambu-gray">
  141. <p>{t('spoolman.noUnlinkedSpools')}</p>
  142. </div>
  143. )}
  144. </div>
  145. </div>
  146. {/* Footer */}
  147. <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
  148. <Button variant="secondary" onClick={onClose}>
  149. {t('common.cancel')}
  150. </Button>
  151. <Button
  152. onClick={handleLink}
  153. disabled={!selectedSpoolId || linkMutation.isPending}
  154. >
  155. {linkMutation.isPending ? (
  156. <>
  157. <Loader2 className="w-4 h-4 animate-spin" />
  158. {t('spoolman.syncing')}
  159. </>
  160. ) : (
  161. <>
  162. <Link2 className="w-4 h-4" />
  163. {t('spoolman.linkToSpoolman')}
  164. </>
  165. )}
  166. </Button>
  167. </div>
  168. {/* Error */}
  169. {linkMutation.isError && (
  170. <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
  171. {(linkMutation.error as Error).message}
  172. </div>
  173. )}
  174. </div>
  175. </div>
  176. );
  177. }