SpoolmanFilamentPicker.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import { useState, useRef, useEffect, useMemo } from 'react';
  2. import { ChevronDown, Loader2, Package } from 'lucide-react';
  3. import { useTranslation } from 'react-i18next';
  4. import type { SpoolmanFilamentEntry } from '../../api/client';
  5. import { getSwatchStyle } from '../../utils/colors';
  6. interface SpoolmanFilamentPickerProps {
  7. filaments: SpoolmanFilamentEntry[];
  8. isLoading: boolean;
  9. selectedId: number | null;
  10. onSelect: (filament: SpoolmanFilamentEntry) => void;
  11. }
  12. export function SpoolmanFilamentPicker({
  13. filaments,
  14. isLoading,
  15. selectedId,
  16. onSelect,
  17. }: SpoolmanFilamentPickerProps) {
  18. const { t } = useTranslation();
  19. const [isOpen, setIsOpen] = useState(false);
  20. const [search, setSearch] = useState('');
  21. const containerRef = useRef<HTMLDivElement>(null);
  22. const inputRef = useRef<HTMLInputElement>(null);
  23. const selected = useMemo(
  24. () => filaments.find((f) => f.id === selectedId) ?? null,
  25. [filaments, selectedId]
  26. );
  27. useEffect(() => {
  28. const handleClick = (e: MouseEvent) => {
  29. if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
  30. setIsOpen(false);
  31. setSearch('');
  32. }
  33. };
  34. document.addEventListener('mousedown', handleClick);
  35. return () => document.removeEventListener('mousedown', handleClick);
  36. }, []);
  37. const filtered = useMemo(() => {
  38. const q = search.toLowerCase().trim();
  39. if (!q) return filaments;
  40. return filaments.filter(
  41. (f) =>
  42. f.name.toLowerCase().includes(q) ||
  43. (f.material?.toLowerCase().includes(q) ?? false) ||
  44. (f.vendor?.name.toLowerCase().includes(q) ?? false) ||
  45. (f.color_name?.toLowerCase().includes(q) ?? false)
  46. );
  47. }, [filaments, search]);
  48. useEffect(() => {
  49. if (isOpen) inputRef.current?.focus();
  50. }, [isOpen]);
  51. const handleOpen = () => {
  52. setIsOpen(true);
  53. setSearch('');
  54. };
  55. const handleSelect = (filament: SpoolmanFilamentEntry) => {
  56. onSelect(filament);
  57. setIsOpen(false);
  58. setSearch('');
  59. };
  60. return (
  61. <div ref={containerRef} className="relative">
  62. <label className="block text-sm font-medium text-bambu-gray mb-1">
  63. {t('inventory.pickFromSpoolmanCatalog')}
  64. </label>
  65. {/* Trigger button */}
  66. <button
  67. type="button"
  68. onClick={handleOpen}
  69. className="w-full flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-left focus:outline-none focus:border-bambu-green hover:border-bambu-gray transition-colors"
  70. >
  71. {isLoading ? (
  72. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin shrink-0" />
  73. ) : selected ? (
  74. <>
  75. <span
  76. className="w-4 h-4 rounded-full shrink-0 border border-white/20"
  77. style={getSwatchStyle(selected.color_hex)}
  78. aria-label={t('inventory.spoolmanFilamentColorSwatch')}
  79. />
  80. <span className="text-white text-sm truncate flex-1">
  81. {selected.vendor?.name ? `${selected.vendor.name} — ` : ''}
  82. {selected.name}
  83. </span>
  84. </>
  85. ) : (
  86. <>
  87. <Package className="w-4 h-4 text-bambu-gray shrink-0" />
  88. <span className="text-bambu-gray text-sm flex-1">
  89. {t('inventory.spoolmanFilamentCatalog')}
  90. </span>
  91. </>
  92. )}
  93. <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0 ml-auto" />
  94. </button>
  95. {/* Dropdown */}
  96. {isOpen && (
  97. <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl">
  98. {/* Search */}
  99. <div className="p-2 border-b border-bambu-dark-tertiary">
  100. <input
  101. ref={inputRef}
  102. type="text"
  103. value={search}
  104. onChange={(e) => setSearch(e.target.value)}
  105. placeholder={t('inventory.pickFromSpoolmanCatalog')}
  106. className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
  107. />
  108. </div>
  109. {/* Items */}
  110. <ul className="max-h-56 overflow-y-auto py-1">
  111. {isLoading ? (
  112. <li className="flex items-center justify-center gap-2 py-4 text-bambu-gray text-sm">
  113. <Loader2 className="w-4 h-4 animate-spin" />
  114. <span>{t('common.loading', 'Loading…')}</span>
  115. </li>
  116. ) : filtered.length === 0 ? (
  117. <li className="py-4 text-center text-bambu-gray text-sm">
  118. {t('inventory.noSpoolmanFilaments')}
  119. </li>
  120. ) : (
  121. filtered.map((f) => (
  122. <li key={f.id}>
  123. <button
  124. type="button"
  125. onClick={() => handleSelect(f)}
  126. className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-bambu-dark-tertiary transition-colors ${
  127. f.id === selectedId ? 'bg-bambu-dark-tertiary' : ''
  128. }`}
  129. >
  130. <span
  131. className="w-4 h-4 rounded-full shrink-0 border border-white/20"
  132. style={getSwatchStyle(f.color_hex)}
  133. aria-label={t('inventory.spoolmanFilamentColorSwatch')}
  134. />
  135. <span className="flex-1 min-w-0">
  136. <span className="block text-white text-sm truncate">
  137. {f.vendor?.name ? `${f.vendor.name} — ` : ''}
  138. {f.name}
  139. </span>
  140. <span className="block text-bambu-gray text-xs truncate">
  141. {[f.material, f.color_name].filter(Boolean).join(' · ')}
  142. {f.weight ? ` · ${f.weight}g` : ''}
  143. </span>
  144. </span>
  145. </button>
  146. </li>
  147. ))
  148. )}
  149. </ul>
  150. </div>
  151. )}
  152. </div>
  153. );
  154. }