mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 04:31:28 +02:00
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>
This commit is contained in:
@@ -383,37 +383,37 @@ export function ColorCatalogSettings() {
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setFormHexColor(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
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"
|
||||
value={formHexColor}
|
||||
onChange={(e) => setFormHexColor(e.target.value)}
|
||||
/>
|
||||
</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
|
||||
onClick={handleAdd}
|
||||
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" />}
|
||||
{t('common.add')}
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -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<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
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-red-400">{errors.slicer_filament}</p>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* Empty Spool Weight */}
|
||||
@@ -206,12 +214,24 @@ export function AdditionalSection({
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={remainingWeight}
|
||||
value={remainingInput}
|
||||
min={0}
|
||||
max={formData.label_weight}
|
||||
onFocus={() => 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"
|
||||
/>
|
||||
|
||||
@@ -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<CatalogDisplayColor[]>(() => {
|
||||
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<CatalogDisplayColor[]>(() => {
|
||||
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({
|
||||
</div>
|
||||
|
||||
{/* Color Swatches */}
|
||||
{hasCatalogMatch ? (
|
||||
{showCatalogSection ? (
|
||||
/* Catalog colors matching selected brand/material */
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filteredCatalogColors.map(color => (
|
||||
{catalogSearchResults.map(color => (
|
||||
<button
|
||||
key={`${color.hex}-${color.name}`}
|
||||
key={`${color.hex}-${color.name}-${color.manufacturer ?? ''}`}
|
||||
type="button"
|
||||
onClick={() => selectColor(color.hex, color.name)}
|
||||
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'
|
||||
}`}
|
||||
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">
|
||||
{color.name}
|
||||
{color.manufacturer && color.material
|
||||
? `${color.name} (${color.manufacturer} — ${color.material})`
|
||||
: color.manufacturer
|
||||
? `${color.name} (${color.manufacturer})`
|
||||
: color.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredCatalogColors.length === 0 && (
|
||||
{catalogSearchResults.length === 0 && (
|
||||
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const brandRef = useRef<HTMLDivElement>(null);
|
||||
const subtypeRef = useRef<HTMLDivElement>(null);
|
||||
const materialRef = useRef<HTMLDivElement>(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 */}
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Brand (dropdown with search) */}
|
||||
<div>
|
||||
<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">
|
||||
<input
|
||||
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"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2479,6 +2479,7 @@ export default {
|
||||
brand: 'ブランド',
|
||||
searchBrand: 'ブランドを検索...',
|
||||
useCustomBrand: '「{{brand}}」を使用',
|
||||
useCustomMaterial: 'カスタム素材を使用: {{material}}',
|
||||
colorName: '色名',
|
||||
colorNamePlaceholder: 'Jade White, Fire Red...',
|
||||
color: '色',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user