mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Sync
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user