diff --git a/frontend/src/components/ColorCatalogSettings.tsx b/frontend/src/components/ColorCatalogSettings.tsx index b5b6e8ef3..b21ab6178 100644 --- a/frontend/src/components/ColorCatalogSettings.tsx +++ b/frontend/src/components/ColorCatalogSettings.tsx @@ -383,37 +383,37 @@ export function ColorCatalogSettings() {
setFormHexColor(e.target.value)} /> setFormHexColor(e.target.value)} />
- setFormMaterial(e.target.value)} - /> -
+
+ setFormMaterial(e.target.value)} + /> diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 8407aebe3..cee873bac 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -8,7 +8,8 @@ import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } 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 { ColorSection } from './spool-form/ColorSection'; import { AdditionalSection } from './spool-form/AdditionalSection'; @@ -149,10 +150,88 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration ); // 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([...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([...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>(); + 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>(); + 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 const selectedPresetOption = useMemo( @@ -434,6 +513,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration selectedPresetOption={selectedPresetOption} filamentOptions={filamentOptions} availableBrands={availableBrands} + availableMaterials={availableMaterials} /> {errors.slicer_filament && (

{errors.slicer_filament}

diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index c6277c6f1..7488c348c 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -178,6 +178,8 @@ export function AdditionalSection({ const { showToast } = useToast(); const [measuredInput, setMeasuredInput] = useState(''); 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 measuredDefault = formData.core_weight + remainingWeight; @@ -188,6 +190,12 @@ export function AdditionalSection({ } }, [isMeasuredFocused, measuredDefault]); + useEffect(() => { + if (!isRemainingFocused) { + setRemainingInput(String(remainingWeight)); + } + }, [isRemainingFocused, remainingWeight]); + return (
{/* Empty Spool Weight */} @@ -206,12 +214,24 @@ export function AdditionalSection({
setIsRemainingFocused(true)} 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" /> diff --git a/frontend/src/components/spool-form/ColorSection.tsx b/frontend/src/components/spool-form/ColorSection.tsx index 946b06bbd..b44f2f6ab 100644 --- a/frontend/src/components/spool-form/ColorSection.tsx +++ b/frontend/src/components/spool-form/ColorSection.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import type { ColorSectionProps } from './types'; +import type { ColorSectionProps, CatalogDisplayColor } from './types'; import { QUICK_COLORS, ALL_COLORS } from './constants'; export function ColorSection({ @@ -32,7 +32,7 @@ export function ColorSection({ // Filter catalog colors by the selected brand + material + subtype // 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 - const matchedCatalogColors = useMemo(() => { + const matchedCatalogColors = useMemo(() => { if (catalogColors.length === 0) return []; const brand = formData.brand?.trim(); const material = formData.material?.toLowerCase().trim(); @@ -51,6 +51,19 @@ export function ColorSection({ 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 const fullMaterial = material && subtype ? `${material} ${subtype}` : ''; @@ -64,6 +77,8 @@ export function ColorSection({ return exact.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, })); } // Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk") @@ -77,6 +92,8 @@ export function ColorSection({ return normMatch.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, })); } } @@ -92,6 +109,8 @@ export function ColorSection({ return byMaterial.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, })); } } @@ -99,14 +118,20 @@ export function ColorSection({ return []; }, [catalogColors, formData.brand, formData.material, formData.subtype]); - const hasCatalogMatch = matchedCatalogColors.length > 0; - - // Search within matched catalog colors - const filteredCatalogColors = useMemo(() => { + const catalogSearchResults = useMemo(() => { if (!colorSearch) return matchedCatalogColors; + if (matchedCatalogColors.length === 0) return []; 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 const filteredFallbackColors = useMemo(() => { @@ -165,16 +190,16 @@ export function ColorSection({
{/* Color Swatches */} - {hasCatalogMatch ? ( + {showCatalogSection ? ( /* Catalog colors matching selected brand/material */
{colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
- {filteredCatalogColors.map(color => ( + {catalogSearchResults.map(color => ( ))} - {filteredCatalogColors.length === 0 && ( + {catalogSearchResults.length === 0 && (

{t('inventory.noColorsFound')}

)}
diff --git a/frontend/src/components/spool-form/FilamentSection.tsx b/frontend/src/components/spool-form/FilamentSection.tsx index 297aa9e32..c0bbeed59 100644 --- a/frontend/src/components/spool-form/FilamentSection.tsx +++ b/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 { useTranslation } from 'react-i18next'; import type { FilamentSectionProps, FilamentOption } from './types'; -import { MATERIALS, KNOWN_VARIANTS } from './constants'; +import { KNOWN_VARIANTS } from './constants'; import { parsePresetName } from './utils'; export function FilamentSection({ @@ -15,16 +15,22 @@ export function FilamentSection({ selectedPresetOption, filamentOptions, availableBrands, + availableMaterials, }: FilamentSectionProps) { const { t } = useTranslation(); const [presetDropdownOpen, setPresetDropdownOpen] = useState(false); const [brandDropdownOpen, setBrandDropdownOpen] = useState(false); const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false); + const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false); const [brandSearch, setBrandSearch] = 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(null); const brandRef = useRef(null); const subtypeRef = useRef(null); + const materialRef = useRef(null); // Close dropdowns on outside click useEffect(() => { @@ -32,6 +38,9 @@ export function FilamentSection({ if (presetRef.current && !presetRef.current.contains(e.target as Node)) { setPresetDropdownOpen(false); } + if (materialRef.current && !materialRef.current.contains(e.target as Node)) { + setMaterialDropdownOpen(false); + } if (brandRef.current && !brandRef.current.contains(e.target as Node)) { setBrandDropdownOpen(false); } @@ -57,7 +66,15 @@ export function FilamentSection({ const filteredBrands = useMemo(() => { if (!brandSearch) return availableBrands; 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]); const filteredVariants = useMemo(() => { @@ -66,6 +83,26 @@ export function FilamentSection({ return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search)); }, [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 const handlePresetSelect = (option: FilamentOption) => { updateField('slicer_filament', option.code); @@ -147,18 +184,62 @@ export function FilamentSection({ {/* Material */}
- +
+ { + setMaterialSearch(e.target.value); + setMaterialDropdownOpen(true); + }} + onFocus={() => { + setMaterialDropdownOpen(true); + setMaterialSearch(''); + }} + /> + + {materialDropdownOpen && ( +
+ {filteredMaterials.length === 0 ? ( +
{t('inventory.noResults')}
+ ) : ( + filteredMaterials.map((material) => ( + + )) + )} + {/* Allow custom material */} + {materialSearch && !filteredMaterials.includes(materialSearch) && ( + + )} +
+ )} +
- {/* Brand (dropdown with search) */}
@@ -284,13 +365,28 @@ export function FilamentSection({
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" + 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)); + }} /> g
+
); } diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts index 069f8e197..f8a4a7195 100644 --- a/frontend/src/components/spool-form/types.ts +++ b/frontend/src/components/spool-form/types.ts @@ -1,5 +1,14 @@ + 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 export interface SpoolFormData { material: string; @@ -79,6 +88,7 @@ export interface FilamentSectionProps extends SectionProps { selectedPresetOption?: FilamentOption; filamentOptions: FilamentOption[]; availableBrands: string[]; + availableMaterials: string[]; } // Color section props diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 79f19f24a..60601e6ab 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2552,6 +2552,7 @@ export default { brand: 'Marke', searchBrand: 'Marke suchen...', useCustomBrand: '"{{brand}}" verwenden', + useCustomMaterial: 'Benutzerdefiniertes Material verwenden: {{material}}', colorName: 'Farbname', colorNamePlaceholder: 'Jade White, Fire Red...', color: 'Farbe', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d77f506e8..61f3c4995 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2552,6 +2552,7 @@ export default { brand: 'Brand', searchBrand: 'Search brand...', useCustomBrand: 'Use "{{brand}}"', + useCustomMaterial: 'Use custom material: {{material}}', colorName: 'Color Name', colorNamePlaceholder: 'Jade White, Fire Red...', color: 'Color', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index a4241dff9..26d7bc8de 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2540,6 +2540,7 @@ export default { brand: 'Marque', searchBrand: 'Chercher marque...', useCustomBrand: 'Utiliser "{{brand}}"', + useCustomMaterial: 'Utiliser un matériau personnalisé : {{material}}', colorName: 'Nom de couleur', colorNamePlaceholder: 'Jade White, Fire Red...', color: 'Couleur', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 25c848003..831117bc4 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2357,6 +2357,7 @@ export default { brand: 'Marchio', searchBrand: 'Cerca marchio...', useCustomBrand: 'Usa "{{brand}}"', + useCustomMaterial: 'Usa materiale personalizzato: {{material}}', colorName: 'Nome Colore', colorNamePlaceholder: 'Jade White, Fire Red...', color: 'Colore', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 1c0c49d2e..e351ec5b7 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2479,6 +2479,7 @@ export default { brand: 'ブランド', searchBrand: 'ブランドを検索...', useCustomBrand: '「{{brand}}」を使用', + useCustomMaterial: 'カスタム素材を使用: {{material}}', colorName: '色名', colorNamePlaceholder: 'Jade White, Fire Red...', color: '色', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 8f92f17e2..91b9a228d 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2552,6 +2552,7 @@ export default { brand: 'Marca', searchBrand: 'Pesquisar marca...', useCustomBrand: 'Usar "{{brand}}"', + useCustomMaterial: 'Usar material personalizado: {{material}}', colorName: 'Nome da Cor', colorNamePlaceholder: 'Jade White, Fire Red...', color: 'Cor',