This commit is contained in:
maziggy
2026-02-12 07:07:50 +01:00
parent 778d1966af
commit ec82092bc7
51 changed files with 16968 additions and 9173 deletions
@@ -0,0 +1,154 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Scale } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AdditionalSectionProps } from './types';
function SpoolWeightPicker({
catalog,
value,
onChange,
}: {
catalog: { id: number; name: string; weight: number }[];
value: number;
onChange: (weight: number) => void;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<number | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const filtered = useMemo(() => {
if (!search) return catalog;
const s = search.toLowerCase();
return catalog.filter(e =>
e.name.toLowerCase().includes(s) ||
e.weight.toString().includes(s),
);
}, [catalog, search]);
// Display value: show catalog name if selected, or the weight
const displayValue = useMemo(() => {
if (isOpen) return search;
if (selectedId) {
const entry = catalog.find(e => e.id === selectedId);
if (entry) return entry.name;
}
const match = catalog.find(e => e.weight === value);
if (match) return match.name;
return '';
}, [isOpen, search, selectedId, catalog, value]);
return (
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">
<span className="flex items-center gap-2">
<Scale className="w-3.5 h-3.5 text-bambu-gray" />
{t('inventory.coreWeight')}
</span>
</label>
<div className="flex gap-2 items-center">
<div className="flex-1 min-w-0 relative" ref={dropdownRef}>
<input
ref={inputRef}
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.searchSpoolWeight')}
value={displayValue}
onFocus={() => {
setIsOpen(true);
setSearch('');
}}
onChange={(e) => {
setSearch(e.target.value);
setIsOpen(true);
}}
/>
{isOpen && (
<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">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</div>
) : (
filtered.map(entry => (
<button
key={entry.id}
type="button"
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex justify-between items-center ${
(selectedId ? entry.id === selectedId : entry.weight === value)
? 'bg-bambu-green/10 text-bambu-green'
: 'text-white'
}`}
onClick={() => {
setSelectedId(entry.id);
onChange(entry.weight);
setIsOpen(false);
setSearch('');
}}
>
<span className="truncate">{entry.name}</span>
<span className="font-mono text-xs text-bambu-gray ml-2 shrink-0">{entry.weight}g</span>
</button>
))
)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<input
type="number"
className="w-16 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm text-center font-mono focus:outline-none focus:border-bambu-green"
value={value}
min={0}
max={2000}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 0) onChange(val);
}}
/>
<span className="text-bambu-gray text-sm">g</span>
</div>
</div>
</div>
);
}
export function AdditionalSection({
formData,
updateField,
spoolCatalog,
}: AdditionalSectionProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Empty Spool Weight */}
<SpoolWeightPicker
catalog={spoolCatalog}
value={formData.core_weight}
onChange={(weight) => updateField('core_weight', weight)}
/>
{/* Note */}
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
<textarea
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 resize-none min-h-[80px]"
placeholder={t('inventory.notePlaceholder')}
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
/>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useState, useMemo } from 'react';
import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ColorSectionProps } from './types';
import { QUICK_COLORS, ALL_COLORS } from './constants';
export function ColorSection({
formData,
updateField,
recentColors,
onColorUsed,
}: ColorSectionProps) {
const { t } = useTranslation();
const [showAllColors, setShowAllColors] = useState(false);
const [colorSearch, setColorSearch] = useState('');
// Current hex without # prefix
const currentHex = formData.rgba.replace('#', '').substring(0, 6);
const isSelected = (hex: string) => {
return currentHex.toUpperCase() === hex.toUpperCase();
};
const selectColor = (hex: string, name: string) => {
// Store as RRGGBBAA (with FF alpha)
updateField('rgba', hex.toUpperCase() + 'FF');
updateField('color_name', name);
onColorUsed({ name, hex });
};
// Colors to show based on search/expand state
const filteredColors = useMemo(() => {
if (colorSearch) {
return ALL_COLORS.filter(c =>
c.name.toLowerCase().includes(colorSearch.toLowerCase()),
);
}
return showAllColors ? ALL_COLORS : QUICK_COLORS;
}, [colorSearch, showAllColors]);
return (
<div className="space-y-3">
{/* Color preview banner */}
<div
className="h-10 rounded-lg border border-bambu-dark-tertiary"
style={{ backgroundColor: `#${currentHex}` }}
/>
{/* Recently Used Colors */}
{recentColors.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 text-xs text-bambu-gray shrink-0">
<Clock className="w-3 h-3" />
<span>{t('inventory.recentColors')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{recentColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Color Search */}
<div className="relative">
<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.searchColors')}
value={colorSearch}
onChange={(e) => setColorSearch(e.target.value)}
/>
</div>
{/* Color Swatches Grid */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-bambu-gray">
<span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
{!colorSearch && (
<button
type="button"
onClick={() => setShowAllColors(!showAllColors)}
className="flex items-center gap-1 hover:text-white transition-colors"
>
{showAllColors ? (
<>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
) : (
<>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
)}
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{filteredColors.map(color => (
<button
key={color.hex}
type="button"
onClick={() => selectColor(color.hex, color.name)}
className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
isSelected(color.hex)
? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
: 'border-bambu-dark-tertiary'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={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-10 shadow-lg text-white">
{color.name}
</span>
</button>
))}
{filteredColors.length === 0 && (
<p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
)}
</div>
</div>
{/* Manual Color Input */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.colorName')}</label>
<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.colorNamePlaceholder')}
value={formData.color_name}
onChange={(e) => updateField('color_name', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.hexColor')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray">#</span>
<input
type="text"
className="w-full pl-7 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono uppercase focus:outline-none focus:border-bambu-green"
placeholder="RRGGBB"
value={currentHex.toUpperCase()}
onChange={(e) => {
const val = e.target.value.replace('#', '').replace(/[^0-9A-Fa-f]/g, '');
if (val.length <= 8) updateField('rgba', val.toUpperCase() + (val.length <= 6 ? 'FF' : ''));
}}
/>
</div>
<input
type="color"
className="w-11 h-[38px] rounded-lg cursor-pointer border border-bambu-dark-tertiary shrink-0 bg-transparent"
value={`#${currentHex}`}
onChange={(e) => {
const hex = e.target.value.replace('#', '').toUpperCase();
updateField('rgba', hex + 'FF');
}}
title={t('inventory.pickColor')}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
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 { parsePresetName } from './utils';
export function FilamentSection({
formData,
updateField,
cloudAuthenticated,
loadingCloudPresets,
presetInputValue,
setPresetInputValue,
selectedPresetOption,
filamentOptions,
availableBrands,
}: FilamentSectionProps) {
const { t } = useTranslation();
const [presetDropdownOpen, setPresetDropdownOpen] = useState(false);
const [brandDropdownOpen, setBrandDropdownOpen] = useState(false);
const [brandSearch, setBrandSearch] = useState('');
const presetRef = useRef<HTMLDivElement>(null);
const brandRef = 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 (brandRef.current && !brandRef.current.contains(e.target as Node)) {
setBrandDropdownOpen(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();
return availableBrands.filter(b => b.toLowerCase().includes(search));
}, [availableBrands, brandSearch]);
// 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>
<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>
{/* 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">
<input
type="text"
value={formData.subtype}
onChange={(e) => updateField('subtype', e.target.value)}
list="variant-suggestions"
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"
/>
<datalist id="variant-suggestions">
{KNOWN_VARIANTS.map(v => (
<option key={v} value={v} />
))}
</datalist>
</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"
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"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,268 @@
import { ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { CalibrationProfile, PAProfileSectionProps } from './types';
import { isMatchingCalibration } from './utils';
export function PAProfileSection({
formData,
printersWithCalibrations,
selectedProfiles,
setSelectedProfiles,
expandedPrinters,
setExpandedPrinters,
}: PAProfileSectionProps) {
const { t } = useTranslation();
const togglePrinterExpanded = (printerId: string) => {
setExpandedPrinters((prev) => {
const next = new Set(prev);
if (next.has(printerId)) next.delete(printerId);
else next.add(printerId);
return next;
});
};
const toggleProfileSelected = (printerId: string, caliIdx: number, extruderId?: number | null) => {
const key = `${printerId}:${caliIdx}:${extruderId ?? 'null'}`;
const printerNozzleKey = `${printerId}:${extruderId ?? 'null'}`;
setSelectedProfiles((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
// Remove existing profile for same printer/nozzle
for (const existingKey of Array.from(next)) {
const parts = existingKey.split(':');
const existingPrinterNozzle = `${parts[0]}:${parts[2]}`;
if (existingPrinterNozzle === printerNozzleKey) {
next.delete(existingKey);
}
}
next.add(key);
}
return next;
});
};
// Auto-select best matching profiles
const autoSelectProfiles = () => {
const newSelection = new Set<string>();
for (const { printer, calibrations } of printersWithCalibrations) {
if (!printer.connected) continue;
const matchingCals = calibrations.filter(cal =>
isMatchingCalibration(cal, formData),
);
// Group by extruder
const byExtruder = new Map<string, CalibrationProfile[]>();
for (const cal of matchingCals) {
const extKey = `${cal.extruder_id ?? 'null'}`;
if (!byExtruder.has(extKey)) byExtruder.set(extKey, []);
byExtruder.get(extKey)!.push(cal);
}
// Select best (highest K) for each extruder
for (const [extKey, cals] of byExtruder) {
if (cals.length > 0) {
const sorted = [...cals].sort((a, b) => b.k_value - a.k_value);
const best = sorted[0];
newSelection.add(`${printer.id}:${best.cali_idx}:${extKey}`);
}
}
}
setSelectedProfiles(newSelection);
};
if (!formData.material) {
return (
<div className="p-6 bg-bambu-dark rounded-lg text-center">
<p className="text-bambu-gray">
{t('inventory.selectMaterialFirst')}
</p>
</div>
);
}
if (printersWithCalibrations.length === 0) {
return (
<div className="p-6 bg-bambu-dark rounded-lg text-center">
<p className="text-bambu-gray">
{t('inventory.noPrintersConfigured')}
</p>
</div>
);
}
// Count total matching profiles
const totalMatching = printersWithCalibrations.reduce((sum, { printer, calibrations }) => {
if (!printer.connected) return sum;
return sum + calibrations.filter(cal => isMatchingCalibration(cal, formData)).length;
}, 0);
const renderProfile = (printer: { id: number }, cal: CalibrationProfile) => {
const key = `${printer.id}:${cal.cali_idx}:${cal.extruder_id ?? 'null'}`;
const isSelected = selectedProfiles.has(key);
return (
<label
key={`${cal.cali_idx}-${cal.extruder_id}`}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all border ${
isSelected
? 'bg-bambu-green/10 border-bambu-green/30'
: 'bg-bambu-dark border-transparent hover:bg-bambu-dark/80'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleProfileSelected(String(printer.id), cal.cali_idx, cal.extruder_id)}
className="w-4 h-4 rounded border-bambu-dark-tertiary text-bambu-green focus:ring-bambu-green"
/>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${isSelected ? 'text-bambu-green' : 'text-white'}`}>
{cal.name || cal.filament_id}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-bambu-dark text-bambu-gray">
K={cal.k_value.toFixed(3)}
</span>
</div>
</label>
);
};
return (
<div className="space-y-4">
{/* Header with auto-select */}
<div className="flex items-center justify-between">
<p className="text-xs text-bambu-gray">
{t('inventory.matchingFilter')}: {formData.brand || t('inventory.anyBrand')} / {formData.material} / {formData.subtype || t('inventory.anyVariant')}
</p>
{totalMatching > 0 && (
<button
type="button"
onClick={autoSelectProfiles}
className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
{t('inventory.autoSelect')} ({totalMatching})
</button>
)}
</div>
{/* Printer sections */}
<div className="space-y-3">
{printersWithCalibrations.map(({ printer, calibrations }) => {
const isExpanded = expandedPrinters.has(String(printer.id));
const matchingCals = calibrations.filter(cal => isMatchingCalibration(cal, formData));
const matchingCount = matchingCals.length;
// Multi-nozzle grouping
const isMultiNozzle = matchingCals.some(cal =>
cal.extruder_id !== undefined && cal.extruder_id !== null && cal.extruder_id > 0,
);
const leftNozzleCals = matchingCals.filter(cal => cal.extruder_id === 1);
const rightNozzleCals = matchingCals.filter(cal =>
cal.extruder_id === 0 || cal.extruder_id === undefined || cal.extruder_id === null,
);
return (
<div
key={printer.id}
className="border border-bambu-dark-tertiary rounded-lg overflow-hidden"
>
{/* Printer Header */}
<button
type="button"
onClick={() => togglePrinterExpanded(String(printer.id))}
className="w-full px-4 py-3 flex items-center justify-between bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-bambu-gray" />
) : (
<ChevronRight className="w-4 h-4 text-bambu-gray" />
)}
<span className="font-medium text-white">
{printer.name}
</span>
{matchingCount > 0 ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
{matchingCount} {matchingCount !== 1 ? t('inventory.matches') : t('inventory.match')}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray">
{t('inventory.noMatches')}
</span>
)}
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
printer.connected
? 'bg-green-500/20 text-green-500'
: 'bg-bambu-gray/20 text-bambu-gray'
}`}>
{printer.connected ? t('inventory.connected') : t('inventory.offline')}
</span>
</button>
{/* Calibration Profiles */}
{isExpanded && (
<div className="px-4 py-3 space-y-3 bg-bambu-dark border-t border-bambu-dark-tertiary">
{!printer.connected ? (
<p className="text-sm text-bambu-gray italic py-2">
{t('inventory.printerOffline')}
</p>
) : matchingCount === 0 ? (
<p className="text-sm text-bambu-gray italic py-2">
{t('inventory.noKProfilesMatch')}
</p>
) : isMultiNozzle ? (
<>
{leftNozzleCals.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('inventory.leftNozzle')}
</p>
<div className="space-y-2">
{leftNozzleCals.map(cal => renderProfile(printer, cal))}
</div>
</div>
)}
{rightNozzleCals.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-bambu-gray uppercase tracking-wide">
{t('inventory.rightNozzle')}
</p>
<div className="space-y-2">
{rightNozzleCals.map(cal => renderProfile(printer, cal))}
</div>
</div>
)}
</>
) : (
<div className="space-y-2">
{matchingCals.map(cal => renderProfile(printer, cal))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Summary */}
{selectedProfiles.size > 0 && (
<div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
<p className="text-sm text-white">
<span className="font-semibold">{selectedProfiles.size}</span> {t('inventory.profilesSelected')}
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import type { ColorPreset } from './types';
// Material options
export const MATERIALS = [
'PLA', 'PETG', 'ABS', 'TPU', 'ASA', 'PC', 'PA', 'PVA', 'HIPS',
'PA-CF', 'PETG-CF', 'PLA-CF',
];
// Common spool weights
export const WEIGHTS = [250, 500, 750, 1000, 2000, 3000];
// Default brand options (will be augmented with cloud presets)
export const DEFAULT_BRANDS = [
'Bambu', 'PolyLite', 'PolyTerra', 'eSUN', 'Overture',
'Fiberon', 'SUNLU', 'Inland', 'Hatchbox', 'Generic',
];
// Known filament variants/subtypes
export const KNOWN_VARIANTS = [
'Basic', 'Matte', 'Silk', 'Tough', 'HF', 'High Flow', 'Engineering',
'Galaxy', 'Glow', 'Marble', 'Metal', 'Rainbow', 'Sparkle', 'Wood',
'Translucent', 'Transparent', 'Clear', 'Lite', 'Pro', 'Plus', 'Max',
'Super', 'Ultra', 'Flex', 'Soft', 'Hard', 'Strong', 'Impact',
'Heat Resistant', 'UV Resistant', 'ESD', 'Conductive', 'Magnetic',
'Gradient', 'Dual Color', 'Tri Color', 'Multicolor',
];
// Quick color swatches - most common colors (shown by default)
export const QUICK_COLORS: ColorPreset[] = [
{ name: 'Black', hex: '000000' },
{ name: 'White', hex: 'FFFFFF' },
{ name: 'Gray', hex: '808080' },
{ name: 'Red', hex: 'FF0000' },
{ name: 'Orange', hex: 'FFA500' },
{ name: 'Yellow', hex: 'FFFF00' },
{ name: 'Green', hex: '00AE42' },
{ name: 'Blue', hex: '0066FF' },
{ name: 'Purple', hex: '8B00FF' },
{ name: 'Pink', hex: 'FF69B4' },
{ name: 'Brown', hex: '8B4513' },
{ name: 'Silver', hex: 'C0C0C0' },
];
// Extended color palette (shown when expanded)
export const EXTENDED_COLORS: ColorPreset[] = [
// Reds
{ name: 'Dark Red', hex: '8B0000' },
{ name: 'Crimson', hex: 'DC143C' },
{ name: 'Coral', hex: 'FF7F50' },
{ name: 'Salmon', hex: 'FA8072' },
// Oranges
{ name: 'Dark Orange', hex: 'FF8C00' },
{ name: 'Peach', hex: 'FFDAB9' },
// Yellows
{ name: 'Gold', hex: 'FFD700' },
{ name: 'Khaki', hex: 'F0E68C' },
{ name: 'Lemon', hex: 'FFF44F' },
// Greens
{ name: 'Lime', hex: '32CD32' },
{ name: 'Forest Green', hex: '228B22' },
{ name: 'Olive', hex: '808000' },
{ name: 'Mint', hex: '98FF98' },
{ name: 'Teal', hex: '008080' },
// Blues
{ name: 'Navy', hex: '000080' },
{ name: 'Sky Blue', hex: '87CEEB' },
{ name: 'Royal Blue', hex: '4169E1' },
{ name: 'Cyan', hex: '00FFFF' },
{ name: 'Turquoise', hex: '40E0D0' },
// Purples
{ name: 'Violet', hex: 'EE82EE' },
{ name: 'Magenta', hex: 'FF00FF' },
{ name: 'Indigo', hex: '4B0082' },
{ name: 'Lavender', hex: 'E6E6FA' },
{ name: 'Plum', hex: 'DDA0DD' },
// Pinks
{ name: 'Hot Pink', hex: 'FF69B4' },
{ name: 'Rose', hex: 'FF007F' },
{ name: 'Blush', hex: 'FFB6C1' },
// Browns
{ name: 'Chocolate', hex: 'D2691E' },
{ name: 'Tan', hex: 'D2B48C' },
{ name: 'Beige', hex: 'F5F5DC' },
{ name: 'Maroon', hex: '800000' },
// Neutrals
{ name: 'Dark Gray', hex: '404040' },
{ name: 'Light Gray', hex: 'D3D3D3' },
{ name: 'Charcoal', hex: '36454F' },
{ name: 'Ivory', hex: 'FFFFF0' },
// Bambu specific
{ name: 'Bambu Green', hex: '00AE42' },
{ name: 'Jade White', hex: 'E8E8E8' },
{ name: 'Titan Gray', hex: '5A5A5A' },
];
// All colors combined
export const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];
// Local storage keys
export const RECENT_COLORS_KEY = 'bambuddy-recent-colors';
export const MAX_RECENT_COLORS = 8;
+124
View File
@@ -0,0 +1,124 @@
import type { Printer, SpoolKProfile } from '../../api/client';
// Form data structure
export interface SpoolFormData {
material: string;
subtype: string;
brand: string;
color_name: string;
rgba: string;
label_weight: number;
core_weight: number;
slicer_filament: string;
note: string;
}
export const defaultFormData: SpoolFormData = {
material: '',
subtype: '',
brand: '',
color_name: '',
rgba: '808080FF',
label_weight: 1000,
core_weight: 250,
slicer_filament: '',
note: '',
};
// Printer with calibrations type
export interface PrinterWithCalibrations {
printer: Printer & { connected?: boolean };
calibrations: CalibrationProfile[];
}
// Calibration profile from printer status
export interface CalibrationProfile {
cali_idx: number;
filament_id: string;
setting_id: string;
name: string;
k_value: number;
n_coef: number;
extruder_id?: number | null;
nozzle_diameter?: string;
}
// Filament option from presets
export interface FilamentOption {
code: string;
name: string;
displayName: string;
isCustom: boolean;
allCodes: string[];
}
// Color preset
export interface ColorPreset {
name: string;
hex: string;
}
// Section props base
export interface SectionProps {
formData: SpoolFormData;
updateField: <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => void;
}
// Filament section props
export interface FilamentSectionProps extends SectionProps {
cloudAuthenticated: boolean;
loadingCloudPresets: boolean;
presetInputValue: string;
setPresetInputValue: (value: string) => void;
selectedPresetOption?: FilamentOption;
filamentOptions: FilamentOption[];
availableBrands: string[];
}
// Color section props
export interface ColorSectionProps extends SectionProps {
recentColors: ColorPreset[];
onColorUsed: (color: ColorPreset) => void;
}
// Additional section props
export interface AdditionalSectionProps extends SectionProps {
spoolCatalog: { id: number; name: string; weight: number }[];
}
// PA Profile section props
export interface PAProfileSectionProps extends SectionProps {
printersWithCalibrations: PrinterWithCalibrations[];
selectedProfiles: Set<string>;
setSelectedProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
expandedPrinters: Set<string>;
setExpandedPrinters: React.Dispatch<React.SetStateAction<Set<string>>>;
}
// Validation result
export interface ValidationResult {
isValid: boolean;
errors: Partial<Record<keyof SpoolFormData, string>>;
}
export function validateForm(formData: SpoolFormData): ValidationResult {
const errors: Partial<Record<keyof SpoolFormData, string>> = {};
if (!formData.slicer_filament) {
errors.slicer_filament = 'Slicer preset is required';
}
if (!formData.material) {
errors.material = 'Material is required';
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Existing K-profile for a spool (from saved data)
export interface SavedKProfile extends SpoolKProfile {
printer_serial?: string;
}
+253
View File
@@ -0,0 +1,253 @@
import type { SlicerSetting } from '../../api/client';
import type { ColorPreset, FilamentOption } from './types';
import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
// Fallback filament presets when cloud is not available
const FALLBACK_PRESETS: FilamentOption[] = [
{ code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
{ code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
{ code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
{ code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
{ code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
{ code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
{ code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
{ code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
{ code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
{ code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
{ code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
{ code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
{ code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
{ code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
{ code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
{ code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
{ code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
];
// Parse a slicer preset name to extract brand, material, and variant
export function parsePresetName(name: string): { brand: string; material: string; variant: string } {
// Remove @printer suffix (e.g., "@Bambu Lab H2D 0.4 nozzle")
let cleanName = name.replace(/@.*$/, '').trim();
// Remove (Custom) tag
cleanName = cleanName.replace(/\(Custom\)/i, '').trim();
// Remove leading # or * markers
cleanName = cleanName.replace(/^[#*]+\s*/, '').trim();
// Materials list - order matters (longer/more specific first)
const materials = [
'PLA-CF', 'PETG-CF', 'ABS-GF', 'ASA-CF', 'PA-CF', 'PAHT-CF', 'PA6-CF', 'PA6-GF',
'PPA-CF', 'PPA-GF', 'PET-CF', 'PPS-CF', 'PC-CF', 'PC-ABS', 'ABS-GF',
'PETG', 'PLA', 'ABS', 'ASA', 'PC', 'PA', 'TPU', 'PVA', 'HIPS', 'BVOH', 'PPS', 'PCTG', 'PEEK', 'PEI',
];
// Find material in the name
let material = '';
let materialIdx = -1;
for (const m of materials) {
const idx = cleanName.toUpperCase().indexOf(m.toUpperCase());
if (idx !== -1) {
material = m;
materialIdx = idx;
break;
}
}
// Brand is everything before the material
let brand = '';
if (materialIdx > 0) {
brand = cleanName.substring(0, materialIdx).trim();
brand = brand.replace(/[-_\s]+$/, '');
}
// Everything after material is potential variant
let afterMaterial = '';
if (materialIdx !== -1 && material) {
afterMaterial = cleanName.substring(materialIdx + material.length).trim();
afterMaterial = afterMaterial.replace(/^[-_\s]+/, '');
}
// Check for known variant - could be before OR after material
let variant = '';
// First check after material (most common)
for (const v of KNOWN_VARIANTS) {
if (afterMaterial.toLowerCase().includes(v.toLowerCase())) {
variant = v;
break;
}
}
// If no variant found after material, check if brand contains a known variant
if (!variant && brand) {
for (const v of KNOWN_VARIANTS) {
const variantPattern = new RegExp(`\\s+${v}$`, 'i');
if (variantPattern.test(brand)) {
variant = v;
brand = brand.replace(variantPattern, '').trim();
break;
}
}
}
return { brand, material, variant };
}
// Extract unique brands from cloud presets
export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
const brandSet = new Set<string>(DEFAULT_BRANDS);
for (const preset of presets) {
const { brand } = parsePresetName(preset.name);
if (brand && brand.length > 1) {
brandSet.add(brand);
}
}
return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
}
// Build filament options from cloud presets
export function buildFilamentOptions(
cloudPresets: SlicerSetting[],
configuredPrinterModels: Set<string>,
): FilamentOption[] {
if (cloudPresets.length > 0) {
const customPresets: FilamentOption[] = [];
const defaultPresetsMap = new Map<string, FilamentOption>();
for (const preset of cloudPresets) {
if (preset.is_custom) {
// Custom presets: include if matches configured printers or no printer filter
const presetNameUpper = preset.name.toUpperCase();
const matchesPrinter = configuredPrinterModels.size === 0 ||
Array.from(configuredPrinterModels).some(model => presetNameUpper.includes(model)) ||
!presetNameUpper.includes('@');
if (matchesPrinter) {
customPresets.push({
code: preset.setting_id,
name: preset.name,
displayName: `${preset.name} (Custom)`,
isCustom: true,
allCodes: [preset.setting_id],
});
}
} else {
// Default presets: deduplicate by base name
const baseName = preset.name.replace(/@.*$/, '').trim();
const existing = defaultPresetsMap.get(baseName);
if (existing) {
existing.allCodes.push(preset.setting_id);
} else {
defaultPresetsMap.set(baseName, {
code: preset.setting_id,
name: baseName,
displayName: baseName,
isCustom: false,
allCodes: [preset.setting_id],
});
}
}
}
return [
...customPresets,
...Array.from(defaultPresetsMap.values()),
].sort((a, b) => a.displayName.localeCompare(b.displayName));
}
// Fallback to hardcoded defaults
return FALLBACK_PRESETS;
}
// Find selected preset option
export function findPresetOption(
slicerFilament: string,
filamentOptions: FilamentOption[],
): FilamentOption | undefined {
if (!slicerFilament) return undefined;
// First try exact match on primary code
let option = filamentOptions.find(o => o.code === slicerFilament);
if (!option) {
// Try matching against any code in allCodes
option = filamentOptions.find(o => o.allCodes.includes(slicerFilament));
}
if (!option) {
// Try case-insensitive match
const slicerLower = slicerFilament.toLowerCase();
option = filamentOptions.find(o =>
o.code.toLowerCase() === slicerLower ||
o.allCodes.some(c => c.toLowerCase() === slicerLower),
);
}
return option;
}
// Recent colors management
export function loadRecentColors(): ColorPreset[] {
try {
const stored = localStorage.getItem(RECENT_COLORS_KEY);
if (stored) {
return JSON.parse(stored) as ColorPreset[];
}
} catch {
// Ignore errors
}
return [];
}
export function saveRecentColor(color: ColorPreset, currentRecent: ColorPreset[]): ColorPreset[] {
const filtered = currentRecent.filter(
c => c.hex.toUpperCase() !== color.hex.toUpperCase(),
);
const updated = [color, ...filtered].slice(0, MAX_RECENT_COLORS);
try {
localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(updated));
} catch {
// Ignore errors
}
return updated;
}
// Check if a calibration matches based on brand, material, and variant
export function isMatchingCalibration(
cal: { name?: string; filament_id?: string },
formData: { material: string; brand: string; subtype: string },
): boolean {
if (!formData.material) return false;
const profileName = cal.name || '';
// Remove flow type prefixes
const cleanName = profileName
.replace(/^High Flow[_\s]+/i, '')
.replace(/^Standard[_\s]+/i, '')
.replace(/^HF[_\s]+/i, '')
.replace(/^S[_\s]+/i, '')
.trim();
const parsed = parsePresetName(cleanName);
// Match material (required)
const materialMatch = parsed.material.toUpperCase() === formData.material.toUpperCase();
if (!materialMatch) return false;
// Match brand if specified in form
if (formData.brand) {
const brandMatch = parsed.brand.toLowerCase().includes(formData.brand.toLowerCase()) ||
formData.brand.toLowerCase().includes(parsed.brand.toLowerCase());
if (!brandMatch) return false;
}
// Match variant/subtype if specified in form
if (formData.subtype) {
const variantMatch = parsed.variant.toLowerCase().includes(formData.subtype.toLowerCase()) ||
formData.subtype.toLowerCase().includes(parsed.variant.toLowerCase()) ||
cleanName.toLowerCase().includes(formData.subtype.toLowerCase());
if (!variantMatch) return false;
}
return true;
}