SpoolmanFilamentPicker.tsx 6.1 KB

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