Преглед изворни кода

Allow external materials/brands to be used when adding spool (#455)

* feat(spool-form): enhance brand and material selection in color section

* feat(spool-form): change input field behavoiur when deleting all chars, fix scroll menu theming

* feat(spool-form): add available materials to SpoolFormModal and FilamentSection

* refactor(ColorSection): remove unnecessary hasCatalogMatch variable

* feat(SpoolFormModal): add bilateral brand and material filtering logic

* feat(ColorCatalogSettings): improve input field sizes and layout for color and material selection

* Refactor colorCatalog settings and dependencies, allow custom materials, fix ml-12 marging, show catalog colors on brand/material selection

* Refactor ColorCatalogSettings and ColorSection components; remove  onBlur logic in FilamentSection

* Prioritize exact matches in search results

* Added inventory.useCustomMaterial to locales

* Remove unused filteredCatalogColors logic and update button rendering; update types file to import Printer and SpoolKProfile

* Fix indentation for useCustomMaterial

* Refactor showCatalogSection logic to only depend on matched catalog colors

* Remove extra newline before brand dropdown

* Clean up ColorSection.tsx by removing blank lines

Removed unnecessary blank lines in ColorSection.tsx.

* Ensure material property is a string in ColorSection component

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
Keybored пре 6 месеци
родитељ
комит
8b081dbd9a

+ 12 - 12
frontend/src/components/ColorCatalogSettings.tsx

@@ -383,37 +383,37 @@ export function ColorCatalogSettings() {
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <input
                 <input
                   type="color"
                   type="color"
-                  className="w-10 h-10 rounded cursor-pointer border border-bambu-dark-tertiary"
+                  className="w-20 h-10 rounded cursor-pointer border border-bambu-dark-tertiary"
                   value={formHexColor}
                   value={formHexColor}
                   onChange={(e) => setFormHexColor(e.target.value)}
                   onChange={(e) => setFormHexColor(e.target.value)}
                 />
                 />
                 <input
                 <input
                   type="text"
                   type="text"
-                  className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  className="w-32 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
                   placeholder="#FFFFFF"
                   placeholder="#FFFFFF"
                   value={formHexColor}
                   value={formHexColor}
                   onChange={(e) => setFormHexColor(e.target.value)}
                   onChange={(e) => setFormHexColor(e.target.value)}
                 />
                 />
               </div>
               </div>
-              <input
-                type="text"
-                className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
-                placeholder={t('settings.colorCatalog.materialOptional')}
-                value={formMaterial}
-                onChange={(e) => setFormMaterial(e.target.value)}
-              />
-              <div className="flex gap-2">
+              <div className="flex items-center gap-1">
+                <input
+                  type="text"
+                  className="flex-1 max-w-lg min-w-[200px] px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  placeholder={t('settings.colorCatalog.materialOptional')}
+                  value={formMaterial}
+                  onChange={(e) => setFormMaterial(e.target.value)}
+                />
                 <button
                 <button
                   onClick={handleAdd}
                   onClick={handleAdd}
                   disabled={saving}
                   disabled={saving}
-                  className="flex-1 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-1"
+                  className="w-24 px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center justify-center gap-3"
                 >
                 >
                   {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
                   {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
                   {t('common.add')}
                   {t('common.add')}
                 </button>
                 </button>
                 <button
                 <button
                   onClick={() => { setShowAddForm(false); resetForm(); }}
                   onClick={() => { setShowAddForm(false); resetForm(); }}
-                  className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+                  className="p-2 ml-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
                 >
                 >
                   <X className="w-4 h-4" />
                   <X className="w-4 h-4" />
                 </button>
                 </button>

+ 85 - 5
frontend/src/components/SpoolFormModal.tsx

@@ -8,7 +8,8 @@ import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import { defaultFormData, validateForm } from './spool-form/types';
 import { defaultFormData, validateForm } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, saveRecentColor } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
+import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
@@ -149,10 +150,88 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
   );
   );
 
 
   // Extract brands from presets
   // Extract brands from presets
-  const availableBrands = useMemo(
-    () => extractBrandsFromPresets(cloudPresets, localPresets),
-    [cloudPresets, localPresets],
-  );
+  const baseAvailableBrands = useMemo(() => {
+    const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
+    const catalogBrands = colorCatalog
+      .map(entry => entry.manufacturer?.trim())
+      .filter((brand): brand is string => !!brand);
+    const brandSet = new Set<string>([...presetBrands, ...catalogBrands]);
+    return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
+  }, [cloudPresets, localPresets, colorCatalog]);
+
+  const baseAvailableMaterials = useMemo(() => {
+    const catalogMaterials = colorCatalog
+      .map(entry => entry.material?.trim())
+      .filter((material): material is string => !!material);
+    const materialSet = new Set<string>([...MATERIALS, ...catalogMaterials]);
+    return Array.from(materialSet).sort((a, b) => a.localeCompare(b));
+  }, [colorCatalog]);
+
+  const brandMaterialPairs = useMemo(() => {
+    const pairs: Array<{ brand: string; material: string }> = [];
+
+    for (const entry of colorCatalog) {
+      const brand = entry.manufacturer?.trim();
+      const material = entry.material?.trim();
+      if (brand && material) pairs.push({ brand, material });
+    }
+
+    for (const preset of cloudPresets) {
+      const parsed = parsePresetName(preset.name);
+      if (parsed.brand && parsed.material) {
+        pairs.push({ brand: parsed.brand, material: parsed.material });
+      }
+    }
+
+    for (const preset of localPresets) {
+      const parsed = parsePresetName(preset.name);
+      const brand = preset.filament_vendor?.trim() || parsed.brand;
+      const material = parsed.material;
+      if (brand && material) {
+        pairs.push({ brand, material });
+      }
+    }
+
+    return pairs;
+  }, [cloudPresets, colorCatalog, localPresets]);
+
+  const brandToMaterials = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    for (const pair of brandMaterialPairs) {
+      const brandKey = pair.brand.toLowerCase();
+      const materialKey = pair.material.toLowerCase();
+      if (!map.has(brandKey)) map.set(brandKey, new Set());
+      map.get(brandKey)!.add(materialKey);
+    }
+    return map;
+  }, [brandMaterialPairs]);
+
+  const materialToBrands = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    for (const pair of brandMaterialPairs) {
+      const brandKey = pair.brand.toLowerCase();
+      const materialKey = pair.material.toLowerCase();
+      if (!map.has(materialKey)) map.set(materialKey, new Set());
+      map.get(materialKey)!.add(brandKey);
+    }
+    return map;
+  }, [brandMaterialPairs]);
+
+  const availableBrands = useMemo(() => {
+    if (!formData.material) return baseAvailableBrands;
+    const materialKey = formData.material.toLowerCase();
+    const brandKeys = materialToBrands.get(materialKey);
+    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
+    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
+  }, [baseAvailableBrands, formData.material, materialToBrands]);
+
+  const availableMaterials = useMemo(() => {
+    if (!formData.brand) return baseAvailableMaterials;
+    const brandKey = formData.brand.toLowerCase();
+    const materialKeys = brandToMaterials.get(brandKey);
+    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
+    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
+  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
 
 
   // Find selected preset option
   // Find selected preset option
   const selectedPresetOption = useMemo(
   const selectedPresetOption = useMemo(
@@ -434,6 +513,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
                   selectedPresetOption={selectedPresetOption}
                   selectedPresetOption={selectedPresetOption}
                   filamentOptions={filamentOptions}
                   filamentOptions={filamentOptions}
                   availableBrands={availableBrands}
                   availableBrands={availableBrands}
+                  availableMaterials={availableMaterials}
                 />
                 />
                 {errors.slicer_filament && (
                 {errors.slicer_filament && (
                   <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
                   <p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>

+ 23 - 3
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -178,6 +178,8 @@ export function AdditionalSection({
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [measuredInput, setMeasuredInput] = useState('');
   const [measuredInput, setMeasuredInput] = useState('');
   const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
   const [isMeasuredFocused, setIsMeasuredFocused] = useState(false);
+  const [remainingInput, setRemainingInput] = useState('');
+  const [isRemainingFocused, setIsRemainingFocused] = useState(false);
 
 
   const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
   const remainingWeight = Math.max(0, formData.label_weight - formData.weight_used);
   const measuredDefault = formData.core_weight + remainingWeight;
   const measuredDefault = formData.core_weight + remainingWeight;
@@ -188,6 +190,12 @@ export function AdditionalSection({
     }
     }
   }, [isMeasuredFocused, measuredDefault]);
   }, [isMeasuredFocused, measuredDefault]);
 
 
+  useEffect(() => {
+    if (!isRemainingFocused) {
+      setRemainingInput(String(remainingWeight));
+    }
+  }, [isRemainingFocused, remainingWeight]);
+
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
       {/* Empty Spool Weight */}
       {/* Empty Spool Weight */}
@@ -206,12 +214,24 @@ export function AdditionalSection({
           <div className="relative flex-1">
           <div className="relative flex-1">
             <input
             <input
               type="number"
               type="number"
-              value={remainingWeight}
+              value={remainingInput}
               min={0}
               min={0}
               max={formData.label_weight}
               max={formData.label_weight}
+              onFocus={() => setIsRemainingFocused(true)}
               onChange={(e) => {
               onChange={(e) => {
-                const remaining = parseInt(e.target.value) || 0;
-                updateField('weight_used', Math.max(0, formData.label_weight - remaining));
+                setRemainingInput(e.target.value);
+              }}
+              onBlur={() => {
+                setIsRemainingFocused(false);
+                const raw = remainingInput.trim();
+                const remaining = Number(raw);
+                if (!raw || !Number.isFinite(remaining) || remaining < 0 || remaining > formData.label_weight) {
+                  setRemainingInput(String(remainingWeight));
+                  return;
+                }
+                const rounded = Math.round(remaining);
+                updateField('weight_used', Math.max(0, formData.label_weight - rounded));
+                setRemainingInput(String(rounded));
               }}
               }}
               className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
               className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
             />
             />

+ 49 - 14
frontend/src/components/spool-form/ColorSection.tsx

@@ -1,7 +1,7 @@
 import { useState, useMemo } from 'react';
 import { useState, useMemo } from 'react';
 import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
 import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import type { ColorSectionProps } from './types';
+import type { ColorSectionProps, CatalogDisplayColor } from './types';
 import { QUICK_COLORS, ALL_COLORS } from './constants';
 import { QUICK_COLORS, ALL_COLORS } from './constants';
 
 
 export function ColorSection({
 export function ColorSection({
@@ -32,7 +32,7 @@ export function ColorSection({
   // Filter catalog colors by the selected brand + material + subtype
   // Filter catalog colors by the selected brand + material + subtype
   // Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
   // Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
   // Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
   // Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
-  const matchedCatalogColors = useMemo(() => {
+  const matchedCatalogColors = useMemo<CatalogDisplayColor[]>(() => {
     if (catalogColors.length === 0) return [];
     if (catalogColors.length === 0) return [];
     const brand = formData.brand?.trim();
     const brand = formData.brand?.trim();
     const material = formData.material?.toLowerCase().trim();
     const material = formData.material?.toLowerCase().trim();
@@ -51,6 +51,19 @@ export function ColorSection({
       return brandWords.some(w => mfrLower.includes(w));
       return brandWords.some(w => mfrLower.includes(w));
     };
     };
 
 
+    // If only brand is provided, return all colors for that manufacturer
+    if (brand && !material) {
+      const byBrand = catalogColors.filter(c => brandMatches(c.manufacturer));
+      if (byBrand.length > 0) {
+        return byBrand.map(c => ({
+          name: c.color_name,
+          hex: c.hex_color.replace('#', '').substring(0, 6),
+          manufacturer: c.manufacturer,
+          material: typeof c.material === 'string' ? c.material : undefined,
+        }));
+      }
+    }
+
     // Build the combined material+subtype string to match catalog entries
     // Build the combined material+subtype string to match catalog entries
     const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
     const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
 
 
@@ -64,6 +77,8 @@ export function ColorSection({
         return exact.map(c => ({
         return exact.map(c => ({
           name: c.color_name,
           name: c.color_name,
           hex: c.hex_color.replace('#', '').substring(0, 6),
           hex: c.hex_color.replace('#', '').substring(0, 6),
+          manufacturer: c.manufacturer,
+          material: typeof c.material === 'string' ? c.material : undefined,
         }));
         }));
       }
       }
       // Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
       // Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
@@ -77,6 +92,8 @@ export function ColorSection({
           return normMatch.map(c => ({
           return normMatch.map(c => ({
             name: c.color_name,
             name: c.color_name,
             hex: c.hex_color.replace('#', '').substring(0, 6),
             hex: c.hex_color.replace('#', '').substring(0, 6),
+            manufacturer: c.manufacturer,
+            material: typeof c.material === 'string' ? c.material : undefined,
           }));
           }));
         }
         }
       }
       }
@@ -92,6 +109,8 @@ export function ColorSection({
         return byMaterial.map(c => ({
         return byMaterial.map(c => ({
           name: c.color_name,
           name: c.color_name,
           hex: c.hex_color.replace('#', '').substring(0, 6),
           hex: c.hex_color.replace('#', '').substring(0, 6),
+          manufacturer: c.manufacturer,
+          material: typeof c.material === 'string' ? c.material : undefined,
         }));
         }));
       }
       }
     }
     }
@@ -99,14 +118,20 @@ export function ColorSection({
     return [];
     return [];
   }, [catalogColors, formData.brand, formData.material, formData.subtype]);
   }, [catalogColors, formData.brand, formData.material, formData.subtype]);
 
 
-  const hasCatalogMatch = matchedCatalogColors.length > 0;
-
-  // Search within matched catalog colors
-  const filteredCatalogColors = useMemo(() => {
+  const catalogSearchResults = useMemo<CatalogDisplayColor[]>(() => {
     if (!colorSearch) return matchedCatalogColors;
     if (!colorSearch) return matchedCatalogColors;
+    if (matchedCatalogColors.length === 0) return [];
     const q = colorSearch.toLowerCase();
     const q = colorSearch.toLowerCase();
-    return matchedCatalogColors.filter(c => c.name.toLowerCase().includes(q));
-  }, [matchedCatalogColors, colorSearch]);
+    const matches = matchedCatalogColors.filter(c =>
+      c.name.toLowerCase().includes(q) ||
+      (c.manufacturer?.toLowerCase().includes(q) ?? false) ||
+      (c.material?.toLowerCase().includes(q) ?? false),
+    );
+    return matches;
+  }, [colorSearch, matchedCatalogColors]);
+
+  // Only show catalog section if there are matched catalog colors
+  const showCatalogSection = matchedCatalogColors.length > 0;
 
 
   // Fallback hardcoded colors for search/expand
   // Fallback hardcoded colors for search/expand
   const filteredFallbackColors = useMemo(() => {
   const filteredFallbackColors = useMemo(() => {
@@ -165,16 +190,16 @@ export function ColorSection({
       </div>
       </div>
 
 
       {/* Color Swatches */}
       {/* Color Swatches */}
-      {hasCatalogMatch ? (
+      {showCatalogSection ? (
         /* Catalog colors matching selected brand/material */
         /* Catalog colors matching selected brand/material */
         <div className="space-y-1.5">
         <div className="space-y-1.5">
           <span className="text-xs text-bambu-gray">
           <span className="text-xs text-bambu-gray">
             {colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
             {colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
           </span>
           </span>
           <div className="flex flex-wrap gap-1.5">
           <div className="flex flex-wrap gap-1.5">
-            {filteredCatalogColors.map(color => (
+            {catalogSearchResults.map(color => (
               <button
               <button
-                key={`${color.hex}-${color.name}`}
+                key={`${color.hex}-${color.name}-${color.manufacturer ?? ''}`}
                 type="button"
                 type="button"
                 onClick={() => selectColor(color.hex, color.name)}
                 onClick={() => selectColor(color.hex, color.name)}
                 className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
                 className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 hover:z-20 relative group ${
@@ -183,14 +208,24 @@ export function ColorSection({
                     : 'border-bambu-dark-tertiary'
                     : 'border-bambu-dark-tertiary'
                 }`}
                 }`}
                 style={{ backgroundColor: `#${color.hex}` }}
                 style={{ backgroundColor: `#${color.hex}` }}
-                title={color.name}
+                title={
+                  color.manufacturer && color.material
+                    ? `${color.name} (${color.manufacturer} — ${color.material})`
+                    : color.manufacturer
+                    ? `${color.name} (${color.manufacturer})`
+                    : color.name
+                }
               >
               >
                 <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
                 <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
-                  {color.name}
+                  {color.manufacturer && color.material
+                    ? `${color.name} (${color.manufacturer} — ${color.material})`
+                    : color.manufacturer
+                    ? `${color.name} (${color.manufacturer})`
+                    : color.name}
                 </span>
                 </span>
               </button>
               </button>
             ))}
             ))}
-            {filteredCatalogColors.length === 0 && (
+            {catalogSearchResults.length === 0 && (
               <p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
               <p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
             )}
             )}
           </div>
           </div>

+ 111 - 15
frontend/src/components/spool-form/FilamentSection.tsx

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useMemo } from 'react';
 import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
 import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { FilamentSectionProps, FilamentOption } from './types';
 import type { FilamentSectionProps, FilamentOption } from './types';
-import { MATERIALS, KNOWN_VARIANTS } from './constants';
+import { KNOWN_VARIANTS } from './constants';
 import { parsePresetName } from './utils';
 import { parsePresetName } from './utils';
 
 
 export function FilamentSection({
 export function FilamentSection({
@@ -15,16 +15,22 @@ export function FilamentSection({
   selectedPresetOption,
   selectedPresetOption,
   filamentOptions,
   filamentOptions,
   availableBrands,
   availableBrands,
+  availableMaterials,
 }: FilamentSectionProps) {
 }: FilamentSectionProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
   const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
   const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
   const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
   const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
   const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false);
+  const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false);
   const [brandSearch, setBrandSearch] = useState('');
   const [brandSearch, setBrandSearch] = useState('');
   const [subtypeSearch, setSubtypeSearch] = useState('');
   const [subtypeSearch, setSubtypeSearch] = useState('');
+  const [materialSearch, setMaterialSearch] = useState('');
+  const [labelInput, setLabelInput] = useState(String(formData.label_weight));
+  const [isLabelFocused, setIsLabelFocused] = useState(false);
   const presetRef = useRef<HTMLDivElement>(null);
   const presetRef = useRef<HTMLDivElement>(null);
   const brandRef = useRef<HTMLDivElement>(null);
   const brandRef = useRef<HTMLDivElement>(null);
   const subtypeRef = useRef<HTMLDivElement>(null);
   const subtypeRef = useRef<HTMLDivElement>(null);
+  const materialRef = useRef<HTMLDivElement>(null);
 
 
   // Close dropdowns on outside click
   // Close dropdowns on outside click
   useEffect(() => {
   useEffect(() => {
@@ -32,6 +38,9 @@ export function FilamentSection({
       if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
       if (presetRef.current && !presetRef.current.contains(e.target as Node)) {
         setPresetDropdownOpen(false);
         setPresetDropdownOpen(false);
       }
       }
+      if (materialRef.current && !materialRef.current.contains(e.target as Node)) {
+        setMaterialDropdownOpen(false);
+      }
       if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
       if (brandRef.current && !brandRef.current.contains(e.target as Node)) {
         setBrandDropdownOpen(false);
         setBrandDropdownOpen(false);
       }
       }
@@ -57,7 +66,15 @@ export function FilamentSection({
   const filteredBrands = useMemo(() => {
   const filteredBrands = useMemo(() => {
     if (!brandSearch) return availableBrands;
     if (!brandSearch) return availableBrands;
     const search = brandSearch.toLowerCase();
     const search = brandSearch.toLowerCase();
-    return availableBrands.filter(b => b.toLowerCase().includes(search));
+    const filtered = availableBrands.filter(b => b.toLowerCase().includes(search));
+    // Sort: exact match first, then others
+    return filtered.sort((a, b) => {
+      const aExact = a.toLowerCase() === search;
+      const bExact = b.toLowerCase() === search;
+      if (aExact && !bExact) return -1;
+      if (!aExact && bExact) return 1;
+      return a.localeCompare(b);
+    });
   }, [availableBrands, brandSearch]);
   }, [availableBrands, brandSearch]);
 
 
   const filteredVariants = useMemo(() => {
   const filteredVariants = useMemo(() => {
@@ -66,6 +83,26 @@ export function FilamentSection({
     return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
     return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search));
   }, [subtypeSearch]);
   }, [subtypeSearch]);
 
 
+  const filteredMaterials = useMemo(() => {
+    if (!materialSearch) return availableMaterials;
+    const search = materialSearch.toLowerCase();
+    const filtered = availableMaterials.filter(m => m.toLowerCase().includes(search));
+    // Sort: exact match first, then others
+    return filtered.sort((a, b) => {
+      const aExact = a.toLowerCase() === search;
+      const bExact = b.toLowerCase() === search;
+      if (aExact && !bExact) return -1;
+      if (!aExact && bExact) return 1;
+      return a.localeCompare(b);
+    });
+  }, [materialSearch, availableMaterials]);
+
+  useEffect(() => {
+    if (!isLabelFocused) {
+      setLabelInput(String(formData.label_weight));
+    }
+  }, [formData.label_weight, isLabelFocused]);
+
   // Handle preset selection
   // Handle preset selection
   const handlePresetSelect = (option: FilamentOption) => {
   const handlePresetSelect = (option: FilamentOption) => {
     updateField('slicer_filament', option.code);
     updateField('slicer_filament', option.code);
@@ -147,18 +184,62 @@ export function FilamentSection({
       {/* Material */}
       {/* Material */}
       <div>
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
-        <select
-          value={formData.material}
-          onChange={(e) => updateField('material', e.target.value)}
-          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
-        >
-          <option value="">{t('inventory.selectMaterial')}</option>
-          {MATERIALS.map((m) => (
-            <option key={m} value={m}>{m}</option>
-          ))}
-        </select>
+        <div className="relative" ref={materialRef}>
+          <input
+            type="text"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.selectMaterial')}
+            value={materialDropdownOpen ? materialSearch : formData.material}
+            onChange={(e) => {
+              setMaterialSearch(e.target.value);
+              setMaterialDropdownOpen(true);
+            }}
+            onFocus={() => {
+              setMaterialDropdownOpen(true);
+              setMaterialSearch('');
+            }}
+          />
+          <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
+          {materialDropdownOpen && (
+            <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
+              {filteredMaterials.length === 0 ? (
+                <div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
+              ) : (
+                filteredMaterials.map((material) => (
+                  <button
+                    key={material}
+                    type="button"
+                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                      formData.material === material ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
+                    }`}
+                    onClick={() => {
+                      updateField('material', material);
+                      setMaterialDropdownOpen(false);
+                      setMaterialSearch('');
+                    }}
+                  >
+                    {material}
+                  </button>
+                ))
+              )}
+              {/* Allow custom material */}
+              {materialSearch && !filteredMaterials.includes(materialSearch) && (
+                <button
+                  type="button"
+                  className="w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary text-bambu-green border-t border-bambu-dark-tertiary"
+                  onClick={() => {
+                    updateField('material', materialSearch);
+                    setMaterialDropdownOpen(false);
+                    setMaterialSearch('');
+                  }}
+                >
+                  {t('inventory.useCustomMaterial', { material: materialSearch })}
+                </button>
+              )}
+            </div>
+          )}
+        </div>
       </div>
       </div>
-
       {/* Brand (dropdown with search) */}
       {/* Brand (dropdown with search) */}
       <div>
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
@@ -284,13 +365,28 @@ export function FilamentSection({
         <div className="relative">
         <div className="relative">
           <input
           <input
             type="number"
             type="number"
-            value={formData.label_weight}
-            onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
             className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
             className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+            value={labelInput}
+            min={0}
+            onFocus={() => setIsLabelFocused(true)}
+            onChange={(e) => setLabelInput(e.target.value)}
+            onBlur={() => {
+              setIsLabelFocused(false);
+              const raw = labelInput.trim();
+              const next = Number(raw);
+              if (!raw || !Number.isFinite(next) || next < 0) {
+                setLabelInput(String(formData.label_weight));
+                return;
+              }
+              const rounded = Math.round(next);
+              updateField('label_weight', rounded);
+              setLabelInput(String(rounded));
+            }}
           />
           />
           <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
           <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
         </div>
         </div>
       </div>
       </div>
+
     </div>
     </div>
   );
   );
 }
 }

+ 10 - 0
frontend/src/components/spool-form/types.ts

@@ -1,5 +1,14 @@
+
 import type { Printer, SpoolKProfile } from '../../api/client';
 import type { Printer, SpoolKProfile } from '../../api/client';
 
 
+// Catalog color display type (moved from component)
+export interface CatalogDisplayColor {
+  name: string;
+  hex: string;
+  manufacturer?: string;
+  material?: string;
+}
+
 // Form data structure
 // Form data structure
 export interface SpoolFormData {
 export interface SpoolFormData {
   material: string;
   material: string;
@@ -79,6 +88,7 @@ export interface FilamentSectionProps extends SectionProps {
   selectedPresetOption?: FilamentOption;
   selectedPresetOption?: FilamentOption;
   filamentOptions: FilamentOption[];
   filamentOptions: FilamentOption[];
   availableBrands: string[];
   availableBrands: string[];
+  availableMaterials: string[];
 }
 }
 
 
 // Color section props
 // Color section props

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -2552,6 +2552,7 @@ export default {
     brand: 'Marke',
     brand: 'Marke',
     searchBrand: 'Marke suchen...',
     searchBrand: 'Marke suchen...',
     useCustomBrand: '"{{brand}}" verwenden',
     useCustomBrand: '"{{brand}}" verwenden',
+    useCustomMaterial: 'Benutzerdefiniertes Material verwenden: {{material}}',
     colorName: 'Farbname',
     colorName: 'Farbname',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: 'Farbe',
     color: 'Farbe',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -2552,6 +2552,7 @@ export default {
     brand: 'Brand',
     brand: 'Brand',
     searchBrand: 'Search brand...',
     searchBrand: 'Search brand...',
     useCustomBrand: 'Use "{{brand}}"',
     useCustomBrand: 'Use "{{brand}}"',
+    useCustomMaterial: 'Use custom material: {{material}}',
     colorName: 'Color Name',
     colorName: 'Color Name',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: 'Color',
     color: 'Color',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -2540,6 +2540,7 @@ export default {
     brand: 'Marque',
     brand: 'Marque',
     searchBrand: 'Chercher marque...',
     searchBrand: 'Chercher marque...',
     useCustomBrand: 'Utiliser "{{brand}}"',
     useCustomBrand: 'Utiliser "{{brand}}"',
+    useCustomMaterial: 'Utiliser un matériau personnalisé : {{material}}',
     colorName: 'Nom de couleur',
     colorName: 'Nom de couleur',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: 'Couleur',
     color: 'Couleur',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -2357,6 +2357,7 @@ export default {
     brand: 'Marchio',
     brand: 'Marchio',
     searchBrand: 'Cerca marchio...',
     searchBrand: 'Cerca marchio...',
     useCustomBrand: 'Usa "{{brand}}"',
     useCustomBrand: 'Usa "{{brand}}"',
+    useCustomMaterial: 'Usa materiale personalizzato: {{material}}',
     colorName: 'Nome Colore',
     colorName: 'Nome Colore',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: 'Colore',
     color: 'Colore',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -2479,6 +2479,7 @@ export default {
     brand: 'ブランド',
     brand: 'ブランド',
     searchBrand: 'ブランドを検索...',
     searchBrand: 'ブランドを検索...',
     useCustomBrand: '「{{brand}}」を使用',
     useCustomBrand: '「{{brand}}」を使用',
+    useCustomMaterial: 'カスタム素材を使用: {{material}}',
     colorName: '色名',
     colorName: '色名',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: '色',
     color: '色',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2552,6 +2552,7 @@ export default {
     brand: 'Marca',
     brand: 'Marca',
     searchBrand: 'Pesquisar marca...',
     searchBrand: 'Pesquisar marca...',
     useCustomBrand: 'Usar "{{brand}}"',
     useCustomBrand: 'Usar "{{brand}}"',
+    useCustomMaterial: 'Usar material personalizado: {{material}}',
     colorName: 'Nome da Cor',
     colorName: 'Nome da Cor',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     colorNamePlaceholder: 'Jade White, Fire Red...',
     color: 'Cor',
     color: 'Cor',