mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
* 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>
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
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 { KNOWN_VARIANTS } from './constants';
|
|
import { parsePresetName } from './utils';
|
|
|
|
export function FilamentSection({
|
|
formData,
|
|
updateField,
|
|
cloudAuthenticated,
|
|
loadingCloudPresets,
|
|
presetInputValue,
|
|
setPresetInputValue,
|
|
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<HTMLDivElement>(null);
|
|
const brandRef = useRef<HTMLDivElement>(null);
|
|
const subtypeRef = useRef<HTMLDivElement>(null);
|
|
const materialRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close dropdowns on outside click
|
|
useEffect(() => {
|
|
const handleClick = (e: MouseEvent) => {
|
|
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);
|
|
}
|
|
if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) {
|
|
setSubtypeDropdownOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClick);
|
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
}, []);
|
|
|
|
// Filtered presets based on search
|
|
const filteredPresets = useMemo(() => {
|
|
if (!presetInputValue) return filamentOptions;
|
|
const search = presetInputValue.toLowerCase();
|
|
return filamentOptions.filter(o =>
|
|
o.displayName.toLowerCase().includes(search) ||
|
|
o.code.toLowerCase().includes(search),
|
|
);
|
|
}, [filamentOptions, presetInputValue]);
|
|
|
|
// Filtered brands
|
|
const filteredBrands = useMemo(() => {
|
|
if (!brandSearch) return availableBrands;
|
|
const search = brandSearch.toLowerCase();
|
|
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(() => {
|
|
if (!subtypeSearch) return KNOWN_VARIANTS;
|
|
const search = subtypeSearch.toLowerCase();
|
|
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);
|
|
setPresetInputValue(option.displayName);
|
|
setPresetDropdownOpen(false);
|
|
|
|
// Auto-fill material, brand, subtype from preset name
|
|
const parsed = parsePresetName(option.name);
|
|
if (parsed.material) updateField('material', parsed.material);
|
|
if (parsed.brand) updateField('brand', parsed.brand);
|
|
if (parsed.variant) updateField('subtype', parsed.variant);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Cloud status indicator */}
|
|
<div className="flex items-center gap-2 text-xs text-bambu-gray">
|
|
{loadingCloudPresets ? (
|
|
<><Loader2 className="w-3 h-3 animate-spin" /> {t('inventory.loadingPresets')}</>
|
|
) : cloudAuthenticated ? (
|
|
<><Cloud className="w-3 h-3 text-bambu-green" /> {t('inventory.cloudConnected')}</>
|
|
) : (
|
|
<><CloudOff className="w-3 h-3" /> {t('inventory.cloudNotConnected')}</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Slicer Preset (autocomplete) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">
|
|
{t('inventory.slicerPreset')} *
|
|
</label>
|
|
<div className="relative" ref={presetRef}>
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
className="w-full pl-9 pr-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.searchPresets')}
|
|
value={presetInputValue}
|
|
onChange={(e) => {
|
|
setPresetInputValue(e.target.value);
|
|
setPresetDropdownOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
setPresetDropdownOpen(true);
|
|
setPresetInputValue('');
|
|
}}
|
|
/>
|
|
{presetDropdownOpen && (
|
|
<div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-64 overflow-y-auto">
|
|
{filteredPresets.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noPresetsFound')}</div>
|
|
) : (
|
|
filteredPresets.map(option => (
|
|
<button
|
|
key={option.code}
|
|
type="button"
|
|
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
|
|
selectedPresetOption?.code === option.code
|
|
? 'bg-bambu-green/10 text-bambu-green'
|
|
: 'text-white'
|
|
}`}
|
|
onClick={() => handlePresetSelect(option)}
|
|
>
|
|
<span className="truncate">{option.displayName}</span>
|
|
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{option.code}</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selectedPresetOption && (
|
|
<div className="mt-1 text-xs text-bambu-gray">
|
|
{t('inventory.selectedPreset')}: <span className="font-mono text-bambu-green">{selectedPresetOption.code}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Material */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.material')} *</label>
|
|
<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>
|
|
{/* Brand (dropdown with search) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
|
|
<div className="relative" ref={brandRef}>
|
|
<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.searchBrand')}
|
|
value={brandDropdownOpen ? brandSearch : formData.brand}
|
|
onChange={(e) => {
|
|
setBrandSearch(e.target.value);
|
|
setBrandDropdownOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
setBrandDropdownOpen(true);
|
|
setBrandSearch('');
|
|
}}
|
|
/>
|
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
|
|
{brandDropdownOpen && (
|
|
<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">
|
|
{filteredBrands.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
|
|
) : (
|
|
filteredBrands.map(brand => (
|
|
<button
|
|
key={brand}
|
|
type="button"
|
|
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
|
formData.brand === brand ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
|
}`}
|
|
onClick={() => {
|
|
updateField('brand', brand);
|
|
setBrandDropdownOpen(false);
|
|
setBrandSearch('');
|
|
}}
|
|
>
|
|
{brand}
|
|
</button>
|
|
))
|
|
)}
|
|
{/* Allow custom brand */}
|
|
{brandSearch && !filteredBrands.includes(brandSearch) && (
|
|
<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('brand', brandSearch);
|
|
setBrandDropdownOpen(false);
|
|
setBrandSearch('');
|
|
}}
|
|
>
|
|
{t('inventory.useCustomBrand', { brand: brandSearch })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Variant / Subtype */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
|
|
<div className="relative" ref={subtypeRef}>
|
|
<input
|
|
type="text"
|
|
value={subtypeDropdownOpen ? subtypeSearch : formData.subtype}
|
|
onChange={(e) => {
|
|
setSubtypeSearch(e.target.value);
|
|
setSubtypeDropdownOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
setSubtypeDropdownOpen(true);
|
|
setSubtypeSearch('');
|
|
}}
|
|
placeholder="Basic, Matte, Silk..."
|
|
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"
|
|
/>
|
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50 pointer-events-none" />
|
|
{subtypeDropdownOpen && (
|
|
<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">
|
|
{filteredVariants.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
|
|
) : (
|
|
filteredVariants.map(variant => (
|
|
<button
|
|
key={variant}
|
|
type="button"
|
|
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
|
|
formData.subtype === variant ? 'bg-bambu-green/10 text-bambu-green' : 'text-white'
|
|
}`}
|
|
onClick={() => {
|
|
updateField('subtype', variant);
|
|
setSubtypeDropdownOpen(false);
|
|
setSubtypeSearch('');
|
|
}}
|
|
>
|
|
{variant}
|
|
</button>
|
|
))
|
|
)}
|
|
{subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && (
|
|
<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('subtype', subtypeSearch);
|
|
setSubtypeDropdownOpen(false);
|
|
setSubtypeSearch('');
|
|
}}
|
|
>
|
|
{t('inventory.useCustomBrand', { brand: subtypeSearch })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Label Weight */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.labelWeight')}</label>
|
|
<div className="relative">
|
|
<input
|
|
type="number"
|
|
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>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|