Files
bambuddy/frontend/src/components/ConfigureAmsSlotModal.tsx
T
copilot-swe-agent[bot]andcadtoolbox 903f43d71a feat(i18n): update frontend components to use translations
- Add i18n support to AddSmartPlugModal.tsx
- Add i18n support to ConfigureAmsSlotModal.tsx
- Add i18n support to RichTextEditor.tsx
- Add i18n support to ExternalLinksSettings.tsx
- Replace all hardcoded English strings with t() calls

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
2026-02-10 14:26:51 +00:00

856 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { KProfile } from '../api/client';
import { Button } from './Button';
interface SlotInfo {
amsId: number;
trayId: number;
trayCount: number;
trayType?: string;
trayColor?: string;
traySubBrands?: string;
trayInfoIdx?: string;
}
// Get proper AMS label (handles HT AMS with ID 128+)
function getAmsLabel(amsId: number, trayCount: number): string {
// External spool
if (amsId === 255) return 'External';
let normalizedId: number;
let isHt = false;
if (amsId >= 128 && amsId <= 135) {
// HT AMS range: 128-135 → A-H
normalizedId = amsId - 128;
isHt = true;
} else if (amsId >= 0 && amsId <= 3) {
// Regular AMS range: 0-3 → A-D
normalizedId = amsId;
// Check tray count as secondary indicator
isHt = trayCount === 1;
} else {
// Unknown range - fallback to A
normalizedId = 0;
}
// Cap to valid letter range (A-H)
normalizedId = Math.max(0, Math.min(normalizedId, 7));
const letter = String.fromCharCode(65 + normalizedId);
return isHt ? `HT-${letter}` : `AMS-${letter}`;
}
// Convert setting_id to tray_info_idx (filament_id format)
// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
function convertToTrayInfoIdx(settingId: string): string {
// Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
// Bambu presets start with "GFS" - remove the 'S' to get filament_id
if (baseId.startsWith('GFS')) {
return 'GF' + baseId.slice(3);
}
// User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
// This follows the pattern that filament_id and setting_id share the same base ID
if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
return baseId; // Use base ID without version suffix
}
// For other formats, use as-is
return baseId;
}
interface ConfigureAmsSlotModalProps {
isOpen: boolean;
onClose: () => void;
printerId: number;
slotInfo: SlotInfo;
nozzleDiameter?: string;
onSuccess?: () => void;
}
// Known filament material types
const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];
// Extract filament type from preset name by finding known material type
function parsePresetName(name: string): { material: string; brand: string; variant: string } {
// Remove printer/nozzle suffix first
const withoutSuffix = name.replace(/@.+$/, '').trim();
// Try to find a known material type in the name
const upperName = withoutSuffix.toUpperCase();
for (const mat of MATERIAL_TYPES) {
// Use word boundary to match whole words only
const regex = new RegExp(`\\b${mat}\\b`, 'i');
if (regex.test(upperName)) {
// Found material, extract brand (everything before material) and variant (after)
const parts = withoutSuffix.split(regex);
const brand = parts[0]?.trim() || '';
const variant = parts[1]?.trim() || '';
return { material: mat, brand, variant };
}
}
// Fallback: assume first word is brand, second is material
const parts = withoutSuffix.split(/\s+/);
if (parts.length >= 2) {
return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };
}
return { material: withoutSuffix, brand: '', variant: '' };
}
// Check if a preset is a user preset (not built-in)
function isUserPreset(settingId: string): boolean {
// Built-in presets have specific patterns, user presets are UUIDs
return !settingId.startsWith('GF') && !settingId.startsWith('P1');
}
// Common color name to hex mapping
const COLOR_NAME_MAP: Record<string, string> = {
// Basic colors
'white': 'FFFFFF',
'black': '000000',
'red': 'FF0000',
'green': '00FF00',
'blue': '0000FF',
'yellow': 'FFFF00',
'cyan': '00FFFF',
'magenta': 'FF00FF',
'orange': 'FFA500',
'purple': '800080',
'pink': 'FFC0CB',
'brown': '8B4513',
'gray': '808080',
'grey': '808080',
// Filament-specific colors
'jade white': 'FFFEF2',
'ivory': 'FFFFF0',
'beige': 'F5F5DC',
'cream': 'FFFDD0',
'silver': 'C0C0C0',
'gold': 'FFD700',
'bronze': 'CD7F32',
'copper': 'B87333',
'navy': '000080',
'teal': '008080',
'olive': '808000',
'maroon': '800000',
'coral': 'FF7F50',
'salmon': 'FA8072',
'lime': '32CD32',
'mint': '98FF98',
'forest green': '228B22',
'sky blue': '87CEEB',
'royal blue': '4169E1',
'turquoise': '40E0D0',
'lavender': 'E6E6FA',
'violet': 'EE82EE',
'plum': 'DDA0DD',
'tan': 'D2B48C',
'chocolate': 'D2691E',
'charcoal': '36454F',
'slate': '708090',
'transparent': '000000', // Will need special handling
'natural': 'F5F5DC',
'wood': 'DEB887',
};
// Quick-select color presets (common filament colors)
// Basic colors shown by default
const QUICK_COLORS_BASIC = [
{ name: 'White', hex: 'FFFFFF' },
{ name: 'Black', hex: '000000' },
{ name: 'Red', hex: 'FF0000' },
{ name: 'Blue', hex: '0000FF' },
{ name: 'Green', hex: '00AA00' },
{ name: 'Yellow', hex: 'FFFF00' },
{ name: 'Orange', hex: 'FFA500' },
{ name: 'Gray', hex: '808080' },
];
// Extended colors shown when expanded
const QUICK_COLORS_EXTENDED = [
{ name: 'Cyan', hex: '00FFFF' },
{ name: 'Magenta', hex: 'FF00FF' },
{ name: 'Purple', hex: '800080' },
{ name: 'Pink', hex: 'FFC0CB' },
{ name: 'Brown', hex: '8B4513' },
{ name: 'Beige', hex: 'F5F5DC' },
{ name: 'Navy', hex: '000080' },
{ name: 'Teal', hex: '008080' },
{ name: 'Lime', hex: '32CD32' },
{ name: 'Gold', hex: 'FFD700' },
{ name: 'Silver', hex: 'C0C0C0' },
{ name: 'Maroon', hex: '800000' },
{ name: 'Olive', hex: '808000' },
{ name: 'Coral', hex: 'FF7F50' },
{ name: 'Salmon', hex: 'FA8072' },
{ name: 'Turquoise', hex: '40E0D0' },
{ name: 'Violet', hex: 'EE82EE' },
{ name: 'Indigo', hex: '4B0082' },
{ name: 'Chocolate', hex: 'D2691E' },
{ name: 'Tan', hex: 'D2B48C' },
{ name: 'Slate', hex: '708090' },
{ name: 'Charcoal', hex: '36454F' },
{ name: 'Ivory', hex: 'FFFFF0' },
{ name: 'Cream', hex: 'FFFDD0' },
];
// Try to convert color name to hex
function colorNameToHex(name: string): string | null {
const normalized = name.toLowerCase().trim();
return COLOR_NAME_MAP[normalized] || null;
}
export function ConfigureAmsSlotModal({
isOpen,
onClose,
printerId,
slotInfo,
nozzleDiameter = '0.4',
onSuccess,
}: ConfigureAmsSlotModalProps) {
const { t } = useTranslation();
const [selectedPresetId, setSelectedPresetId] = useState<string>('');
const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)
const [searchQuery, setSearchQuery] = useState('');
const [showSuccess, setShowSuccess] = useState(false);
const [showExtendedColors, setShowExtendedColors] = useState(false);
// Fetch cloud settings
const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
queryKey: ['cloudSettings'],
queryFn: () => api.getCloudSettings(),
enabled: isOpen,
});
// Fetch K profiles
const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
queryKey: ['kprofiles', printerId, nozzleDiameter],
queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
enabled: isOpen && !!printerId,
});
// Configure slot mutation
const configureMutation = useMutation({
mutationFn: async () => {
if (!selectedPresetId) throw new Error('No filament preset selected');
// Get the selected preset details
const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
if (!selectedPreset) throw new Error('Selected preset not found');
// Parse the preset name for filament info
const parsed = parsePresetName(selectedPreset.name);
// Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
const caliIdx = selectedKProfile?.slot_id ?? -1;
// Use custom color if set, otherwise use current slot color or default
const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
// Create the tray_sub_brands from preset name (without printer/nozzle suffix)
const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
// Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
// For user presets (not starting with GF), fetch the detail to get the real filament_id
if (!selectedPresetId.startsWith('GFS')) {
try {
const detail = await api.getCloudSettingDetail(selectedPresetId);
if (detail.filament_id) {
trayInfoIdx = detail.filament_id;
} else if (detail.base_id) {
// If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
// This is common for user presets that inherit from Bambu presets
trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
}
} catch (e) {
console.warn('Failed to fetch preset detail for filament_id:', e);
// Fall back to derived tray_info_idx
}
}
// Default temp range based on material type
let tempMin = 190;
let tempMax = 230;
const material = parsed.material.toUpperCase();
if (material.includes('PLA')) {
tempMin = 190;
tempMax = 230;
} else if (material.includes('PETG')) {
tempMin = 220;
tempMax = 260;
} else if (material.includes('ABS')) {
tempMin = 240;
tempMax = 280;
} else if (material.includes('ASA')) {
tempMin = 240;
tempMax = 280;
} else if (material.includes('TPU')) {
tempMin = 200;
tempMax = 240;
} else if (material.includes('PC')) {
tempMin = 260;
tempMax = 300;
} else if (material.includes('PA') || material.includes('NYLON')) {
tempMin = 250;
tempMax = 290;
}
// Parse K value from selected profile
const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
// Configure the slot via MQTT
const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
tray_info_idx: trayInfoIdx,
tray_type: parsed.material || 'PLA',
tray_sub_brands: traySubBrands,
tray_color: color + 'FF', // Add alpha
nozzle_temp_min: tempMin,
nozzle_temp_max: tempMax,
cali_idx: caliIdx,
nozzle_diameter: nozzleDiameter,
setting_id: selectedPresetId, // Full setting ID for slicer compatibility
// Pass K profile's filament_id and setting_id for proper linking
kprofile_filament_id: selectedKProfile?.filament_id,
kprofile_setting_id: selectedKProfile?.setting_id || undefined,
// Also pass the K value directly for extrusion_cali_set command
k_value: kValue,
});
// Save the preset mapping so we can display the correct name in the UI
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
// which can't be resolved to a name via the filamentInfo API
try {
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
} catch (e) {
console.warn('Failed to save slot preset mapping:', e);
// Don't fail the whole operation - slot was configured successfully
}
return result;
},
onSuccess: () => {
setShowSuccess(true);
onSuccess?.();
// Close after showing success briefly
setTimeout(() => {
setShowSuccess(false);
onClose();
}, 1500);
},
});
// Reset slot mutation
const resetMutation = useMutation({
mutationFn: async () => {
return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
},
onSuccess: () => {
setShowSuccess(true);
onSuccess?.();
setTimeout(() => {
setShowSuccess(false);
onClose();
}, 1500);
},
});
// Filter filament presets based on search
const filteredPresets = useMemo(() => {
if (!cloudSettings?.filament) return [];
const query = searchQuery.toLowerCase();
return cloudSettings.filament
.filter(p => {
if (!query) return true;
return p.name.toLowerCase().includes(query);
})
.sort((a, b) => {
// Sort user presets first, then alphabetically
const aIsUser = isUserPreset(a.setting_id);
const bIsUser = isUserPreset(b.setting_id);
if (aIsUser && !bIsUser) return -1;
if (!aIsUser && bIsUser) return 1;
return a.name.localeCompare(b.name);
});
}, [cloudSettings?.filament, searchQuery]);
// Get full preset name for K profile filtering (brand + material, without printer suffix)
const selectedPresetInfo = useMemo(() => {
if (!selectedPresetId || !cloudSettings?.filament) return null;
const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
if (!selectedPreset) return null;
// Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
// Strip leading "# " from custom preset names (user convention)
if (nameWithoutSuffix.startsWith('# ')) {
nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
}
const parsed = parsePresetName(nameWithoutSuffix);
return {
fullName: nameWithoutSuffix,
material: parsed.material,
brand: parsed.brand,
};
}, [selectedPresetId, cloudSettings?.filament]);
// For backwards compatibility with the label
const selectedMaterial = selectedPresetInfo?.fullName || '';
const matchingKProfiles = useMemo(() => {
if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
const { fullName, material, brand } = selectedPresetInfo;
const upperFullName = fullName.toUpperCase();
const upperMaterial = material.toUpperCase();
const upperBrand = brand.toUpperCase();
// Material must be at least 2 chars to avoid false positives
if (!upperMaterial || upperMaterial.length < 2) return [];
// Filter profiles - require brand match if brand is present in selected preset
const filtered = kprofilesData.profiles.filter(p => {
const profileName = p.name.toUpperCase();
// If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
// only show profiles that match the brand
if (upperBrand) {
// Must contain the brand name
if (!profileName.includes(upperBrand)) {
return false;
}
// And must contain the material type
if (!profileName.includes(upperMaterial)) {
return false;
}
return true;
}
// No brand in selected preset - match on full name or material
// Priority 1: Exact match with full name
if (profileName.includes(upperFullName)) {
return true;
}
// Priority 2: Material type match (only when no brand specified)
if (profileName.includes(upperMaterial)) {
return true;
}
// Check for common material aliases
const aliases: Record<string, string[]> = {
'NYLON': ['PA', 'PA-CF', 'PA6'],
'PA': ['NYLON'],
};
const materialAliases = aliases[upperMaterial] || [];
for (const alias of materialAliases) {
if (profileName.includes(alias)) {
return true;
}
}
return false;
});
// Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
// Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D
const seen = new Map<string, KProfile>();
for (const profile of filtered) {
const key = `${profile.name}|${profile.k_value}`;
const existing = seen.get(key);
if (!existing) {
seen.set(key, profile);
} else if (profile.extruder_id === 1 && existing.extruder_id === 0) {
// Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile
seen.set(key, profile);
}
}
return Array.from(seen.values());
}, [kprofilesData?.profiles, selectedPresetInfo]);
// Pre-select current profile when modal opens, reset when closes
useEffect(() => {
if (isOpen && cloudSettings?.filament) {
// Try to pre-select current profile based on trayInfoIdx
if (slotInfo.trayInfoIdx) {
const currentPreset = cloudSettings.filament.find(
p => p.setting_id === slotInfo.trayInfoIdx
);
if (currentPreset) {
setSelectedPresetId(currentPreset.setting_id);
}
}
} else if (!isOpen) {
// Reset when modal closes
setSelectedPresetId('');
setSelectedKProfile(null);
setColorHex('');
setColorInput('');
setSearchQuery('');
setShowSuccess(false);
}
}, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
// Auto-select best matching K profile when preset changes
useEffect(() => {
if (matchingKProfiles.length > 0) {
// Auto-select first matching profile
setSelectedKProfile(matchingKProfiles[0]);
} else {
setSelectedKProfile(null);
}
}, [selectedPresetId, matchingKProfiles]);
// Escape key handler
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
const isLoading = settingsLoading || kprofilesLoading;
const canSave = selectedPresetId && !configureMutation.isPending;
// Get display color (custom or slot default)
const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Settings2 className="w-5 h-5 text-bambu-blue" />
<h2 className="text-lg font-semibold text-white">Configure AMS Slot</h2>
</div>
<button
onClick={onClose}
className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Success overlay */}
{showSuccess && (
<div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
<div className="text-center space-y-3">
<CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
<p className="text-lg font-semibold text-white">Slot Configured!</p>
<p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
</div>
</div>
)}
{/* Slot info */}
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
<div className="flex items-center gap-2">
{slotInfo.trayColor && (
<span
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
/>
)}
<span className="text-white font-medium">
{getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
</span>
{slotInfo.traySubBrands && (
<span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
)}
</div>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
</div>
) : (
<>
{/* Filament Profile Select */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
Filament Profile <span className="text-red-400">*</span>
</label>
<div className="relative">
<input
type="text"
placeholder={t('configureAmsSlot.searchPresets')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
/>
<div className="max-h-48 overflow-y-auto space-y-1">
{filteredPresets.length === 0 ? (
<p className="text-center py-4 text-bambu-gray">
{cloudSettings?.filament?.length === 0
? 'No cloud presets. Login to Bambu Cloud to sync.'
: 'No matching presets found.'}
</p>
) : (
filteredPresets.map((preset) => (
<button
key={preset.setting_id}
onClick={() => setSelectedPresetId(preset.setting_id)}
className={`w-full p-2 rounded-lg border text-left transition-colors ${
selectedPresetId === preset.setting_id
? 'bg-bambu-green/20 border-bambu-green'
: 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
}`}
>
<div className="flex items-center justify-between">
<span className="text-white text-sm truncate">{preset.name}</span>
{isUserPreset(preset.setting_id) && (
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
{t('configureAmsSlot.custom')}
</span>
)}
</div>
</button>
))
)}
</div>
</div>
</div>
{/* K Profile Select */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
K Profile (Pressure Advance)
{selectedMaterial && (
<span className="ml-2 text-xs text-bambu-blue">
Filtering for: {selectedMaterial}
</span>
)}
</label>
{matchingKProfiles.length > 0 ? (
<div className="relative">
<select
value={selectedKProfile?.name || ''}
onChange={(e) => {
const profile = matchingKProfiles.find(p => p.name === e.target.value);
setSelectedKProfile(profile || null);
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
>
<option value="">No K profile (use default 0.020)</option>
{matchingKProfiles.map((profile) => (
<option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
{profile.name} (K={profile.k_value})
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
</div>
) : selectedPresetId ? (
<p className="text-sm text-bambu-gray italic py-2">
No matching K profiles found. Default K=0.020 will be used.
</p>
) : (
<span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
Select a filament profile first
</span>
)}
{selectedKProfile && (
<p className="text-xs text-bambu-green mt-1">
K={selectedKProfile.k_value} from printer calibration
</p>
)}
</div>
{/* Optional: Custom color */}
<div>
<label className="block text-sm text-bambu-gray mb-2">
Custom Color (optional)
</label>
{/* Quick color buttons */}
<div className="flex flex-wrap gap-1.5 mb-2">
{QUICK_COLORS_BASIC.map((color) => (
<button
key={color.hex}
onClick={() => {
setColorHex(color.hex);
setColorInput(color.name);
}}
className={`w-7 h-7 rounded-md border-2 transition-all ${
colorHex === color.hex
? 'border-bambu-green scale-110'
: 'border-white/20 hover:border-white/40'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
<button
onClick={() => setShowExtendedColors(!showExtendedColors)}
className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
>
{showExtendedColors ? '−' : '+'}
</button>
</div>
{/* Extended colors (collapsible) */}
{showExtendedColors && (
<div className="flex flex-wrap gap-1.5 mb-2">
{QUICK_COLORS_EXTENDED.map((color) => (
<button
key={color.hex}
onClick={() => {
setColorHex(color.hex);
setColorInput(color.name);
}}
className={`w-7 h-7 rounded-md border-2 transition-all ${
colorHex === color.hex
? 'border-bambu-green scale-110'
: 'border-white/20 hover:border-white/40'
}`}
style={{ backgroundColor: `#${color.hex}` }}
title={color.name}
/>
))}
</div>
)}
{/* Color input: name or hex */}
<div className="flex gap-2 items-center">
<div
className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
style={{ backgroundColor: `#${displayColor}` }}
/>
<input
type="text"
placeholder={t('configureAmsSlot.colorPlaceholder')}
value={colorInput}
onChange={(e) => {
const input = e.target.value;
setColorInput(input);
// Try to parse as color name first
const nameHex = colorNameToHex(input);
if (nameHex) {
setColorHex(nameHex);
} else {
// Try to parse as hex code
const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
if (cleaned.length === 6) {
setColorHex(cleaned);
} else if (cleaned.length === 3) {
// Expand shorthand hex (e.g., F00 -> FF0000)
setColorHex(cleaned.split('').map(c => c + c).join(''));
}
}
}}
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
/>
{colorHex && (
<button
onClick={() => {
setColorHex('');
setColorInput('');
}}
className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
title={t('configureAmsSlot.clearCustomColor')}
>
Clear
</button>
)}
</div>
{colorHex && (
<p className="text-xs text-bambu-gray mt-1.5">
Hex: #{colorHex}
</p>
)}
</div>
</>
)}
</div>
{/* Footer */}
<div className="flex justify-between p-4 border-t border-bambu-dark-tertiary">
{/* Reset button on the left */}
<Button
variant="secondary"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || configureMutation.isPending}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
{resetMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Resetting...
</>
) : (
<>
<RotateCcw className="w-4 h-4" />
Reset Slot
</>
)}
</Button>
{/* Cancel and Configure buttons on the right */}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => configureMutation.mutate()}
disabled={!canSave}
>
{configureMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Configuring...
</>
) : (
<>
<Settings2 className="w-4 h-4" />
Configure Slot
</>
)}
</Button>
</div>
</div>
{/* Error */}
{(configureMutation.isError || resetMutation.isError) && (
<div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
{(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
</div>
)}
</div>
</div>
);
}