|
|
@@ -0,0 +1,853 @@
|
|
|
+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 { 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 [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.length]); // Only trigger when preset changes
|
|
|
+
|
|
|
+ // 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">Settings sent to printer</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="Search presets..."
|
|
|
+ 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">
|
|
|
+ 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="Color name or hex (e.g., brown, FF8800)"
|
|
|
+ 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="Clear custom color"
|
|
|
+ >
|
|
|
+ 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>
|
|
|
+ );
|
|
|
+}
|