mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
Add K-Profile built its Filament dropdown from the profiles already on the printer, so on a printer with none the field was empty, required and unsatisfiable (#2719, reporter @jmoore-skild). The modal's own hint described the dead end: create the profile in Bambu Studio first. The dropdown now uses the app-wide lookup order -- local imported, Orca Cloud, Bambu Cloud, hardcoded built-in table -- same as the AMS slot picker and the SliceModal tier groups. The built-in table is compiled into the backend, so the list can never be empty: a new printer with no cloud account and nothing imported still gets a first profile. Not fixed the way the report suggested. Seeding from /printers/available-filaments would have offered only what happens to be in an AMS right now, which on the reported printer is nothing; its tray_info_idx is empty or a cloud user preset rather than a filament id; it aggregates across every printer of the same model; and it is gated on QUEUE_CREATE, which the K-Profiles page does not hold. The printer indexes its calibration table by filament_id, so the picked preset is reduced to one before anything is sent. Built-in entries and Bambu official cloud presets carry one; a cloud user preset needs its detail fetched (never base_id -- that collapses a custom preset onto its inherited generic, #1053); imported and Orca presets have no Bambu id at all and take the closest generic for their material, via the same table the AMS slot configure flow uses so the two agree. A filament that resolves to nothing is refused with a named error rather than written under a wrong id. Collapses duplicates from two separate causes. A cloud account carries one copy of each filament per printer model, and with the "@BBL <model>" suffix stripped for display those rows are indistinguishable -- deduped within each tier by resolved filament id, by display name for user presets that have none. Cloud setting_ids also carry a "_NN" variant suffix, so the built-in tier's already-covered check never matched and listed the same filament again; the bare id is now recorded alongside. Groups the options by source with an optgroup per tier, styled in index.css: browsers render optgroup labels small, grey and italic, which buries the one thing distinguishing a "Bambu PLA Basic" you imported from the one the built-in table ships. Drops the second getKProfiles(printer, "0.4") query that existed only to seed the old dropdown. It ran concurrently with the main fetch whenever a non-0.4mm nozzle was selected -- the two-requests-in-flight case that made K-profile fetches time out. --- fix(ui): cancel a dialog's deferred close when it unmounts The AMS slot configure and K-Profile dialogs hold a success state briefly and then close themselves -- 1.5s to 4s after the command goes out, so the printer has time to process it before the list refetches. Each did that with a bare setTimeout closing over setState and the parent's onClose, and nothing cancelled it. The timer therefore ran whether or not the dialog was still there. Dismissing it inside that window, or the printer card re-rendering underneath it, left a pending close that fired later and dismissed whatever dialog was open by then. It also threw outright when the surrounding environment was gone first: a test tearing down its DOM before the 1.5s elapsed produced "ReferenceError: window is not defined" out of react-dom's resolveUpdatePriority, reported as an unhandled error against a suite that otherwise passed. Routes all five through a useCancellableTimeout hook -- two in ConfigureAmsSlotModal, three in KProfileModal, the latter with the longest windows and so the widest exposure. Scheduling replaces any pending timer and unmounting clears it.
1779 lines
79 KiB
TypeScript
1779 lines
79 KiB
TypeScript
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
|
||
import { api } from '../api/client';
|
||
import type { KProfile } from '../api/client';
|
||
import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
|
||
import { toFilamentId } from './spool-form/utils';
|
||
import { Button } from './Button';
|
||
import { getAmsLabel } from '../utils/amsHelpers';
|
||
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
||
|
||
interface SlotInfo {
|
||
amsId: number;
|
||
trayId: number;
|
||
trayCount: number;
|
||
trayType?: string;
|
||
trayColor?: string;
|
||
traySubBrands?: string;
|
||
trayInfoIdx?: string;
|
||
extruderId?: number;
|
||
caliIdx?: number | null;
|
||
savedPresetId?: string;
|
||
}
|
||
|
||
// 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;
|
||
printerModel?: string;
|
||
onSuccess?: () => void;
|
||
fullScreen?: boolean;
|
||
}
|
||
|
||
// Known filament material types
|
||
const MATERIAL_TYPES = ['PLA', 'PETG', 'PCTG', '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();
|
||
const upperName = withoutSuffix.toUpperCase();
|
||
|
||
// Handle "X Support for Y" pattern: the filament type is Y, not X.
|
||
// e.g. "PLA Support for PETG PETG Basic" → material is PETG
|
||
const supportMatch = upperName.match(/\bSUPPORT\s+FOR\s+/);
|
||
if (supportMatch) {
|
||
const afterSupport = upperName.slice(supportMatch.index! + supportMatch[0].length);
|
||
for (const mat of MATERIAL_TYPES) {
|
||
const regex = new RegExp(`\\b${mat}\\b`);
|
||
if (regex.test(afterSupport)) {
|
||
const brand = withoutSuffix.slice(0, supportMatch.index).trim();
|
||
return { material: mat, brand, variant: 'Support' };
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try to find a known material type in the name
|
||
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: '' };
|
||
}
|
||
|
||
// Identity of a K-profile inside the picker. Both profile lists are
|
||
// deduplicated on name+k_value, so this is unique across the whole option set
|
||
// — unlike the bare name, which two profiles can share (#2710).
|
||
function kProfileOptionValue(profile: KProfile): string {
|
||
return `${profile.name}|${profile.k_value}`;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// Escape regex metacharacters and turn whitespace into ``\s+`` so a literal
|
||
// model token compiles to a flexible-whitespace word-boundary regex.
|
||
function _tokenToRegex(token: string): RegExp {
|
||
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
||
return new RegExp(`\\b${escaped}\\b`, 'i');
|
||
}
|
||
|
||
// Extract printer model from a preset name → normalized short code
|
||
// (e.g. "X1C", "H2D"). Two strategies in order:
|
||
//
|
||
// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
|
||
// - "@BBL X1C 0.4 nozzle" → "X1C" (short-code form,
|
||
// Bambu Cloud system presets)
|
||
// - "@Bambu Lab X1 Carbon 0.4 nozzle" → "X1C" (long-form, used by
|
||
// user-renamed Bambu Cloud presets and most Orca Cloud profiles —
|
||
// reverse-looked-up via the backend printer-model registry)
|
||
//
|
||
// (2) Body scan — many user-authored / Orca Cloud presets put the printer
|
||
// model at the START of the name with no @ suffix at all (the literal
|
||
// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
|
||
// name for any known model token (every long-name fragment + every short
|
||
// code from the registry) and return the first match. Long-first sort
|
||
// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
|
||
// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
|
||
// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
|
||
// "X1Box" doesn't match "X1").
|
||
//
|
||
// Returns null when neither strategy resolves; the caller keeps such
|
||
// presets visible (can't filter what we can't classify).
|
||
//
|
||
// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
|
||
// keys are "Bambu Lab <long>", values are short codes.
|
||
function extractPresetModel(
|
||
name: string,
|
||
printerModelsLongToShort: Record<string, string>,
|
||
): string | null {
|
||
const atIdx = name.indexOf('@');
|
||
if (atIdx >= 0) {
|
||
const suffix = name.slice(atIdx + 1).trim();
|
||
const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
|
||
if (bblMatch) return bblMatch[1].trim();
|
||
const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
|
||
if (longMatch) {
|
||
const longFragment = longMatch[1].trim();
|
||
const fullKey = `Bambu Lab ${longFragment}`;
|
||
if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
|
||
const lower = fullKey.toLowerCase();
|
||
for (const [k, v] of Object.entries(printerModelsLongToShort)) {
|
||
if (k.toLowerCase() === lower) return v;
|
||
}
|
||
return longFragment;
|
||
}
|
||
}
|
||
|
||
// Body scan — accumulate {token, short} pairs and try long-first.
|
||
const tokens: Array<{ token: string; short: string }> = [];
|
||
const seen = new Set<string>();
|
||
for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
|
||
const fragment = longName.replace(/^Bambu Lab\s+/, '');
|
||
const key = fragment.toLowerCase();
|
||
if (!seen.has(key)) {
|
||
tokens.push({ token: fragment, short });
|
||
seen.add(key);
|
||
}
|
||
const shortKey = short.toLowerCase();
|
||
if (!seen.has(shortKey)) {
|
||
tokens.push({ token: short, short });
|
||
seen.add(shortKey);
|
||
}
|
||
}
|
||
tokens.sort((a, b) => b.token.length - a.token.length);
|
||
for (const { token, short } of tokens) {
|
||
if (_tokenToRegex(token).test(name)) return short;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function ConfigureAmsSlotModal({
|
||
isOpen,
|
||
onClose,
|
||
printerId,
|
||
slotInfo,
|
||
nozzleDiameter = '0.4',
|
||
printerModel,
|
||
onSuccess,
|
||
fullScreen,
|
||
}: 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);
|
||
const scrolledToRef = useRef<string>('');
|
||
// The success state is held briefly before the modal closes itself; that
|
||
// timer must not outlive the modal.
|
||
const { schedule: scheduleClose } = useCancellableTimeout();
|
||
|
||
// Fetch cloud settings (gracefully handle 401 when logged out)
|
||
const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
|
||
queryKey: ['cloudSettings'],
|
||
queryFn: () => api.getCloudSettings(),
|
||
enabled: isOpen,
|
||
retry: false,
|
||
});
|
||
|
||
// Orca Cloud filament profiles, same shape as Bambu Cloud's. Each query
|
||
// is independent — the picker degrades gracefully if Orca Cloud isn't
|
||
// connected (no entries surface, no error banner because we don't want
|
||
// to nag users who deliberately only use Bambu Cloud).
|
||
const { data: orcaCloudList } = useQuery({
|
||
queryKey: ['orcaCloudProfilesForAmsSlot'],
|
||
queryFn: () => api.orcaCloudListProfiles(),
|
||
enabled: isOpen,
|
||
retry: false,
|
||
});
|
||
|
||
// Fetch local presets
|
||
const { data: localPresets, isLoading: localLoading } = useQuery({
|
||
queryKey: ['localPresets'],
|
||
queryFn: () => api.getLocalPresets(),
|
||
enabled: isOpen,
|
||
});
|
||
|
||
// Fetch built-in filament names (static fallback)
|
||
const { data: builtinFilaments, isLoading: builtinLoading } = useQuery({
|
||
queryKey: ['builtinFilaments'],
|
||
queryFn: () => api.getBuiltinFilaments(),
|
||
enabled: isOpen,
|
||
staleTime: Infinity,
|
||
});
|
||
|
||
// Fetch K profiles
|
||
const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
|
||
queryKey: ['kprofiles', printerId, nozzleDiameter],
|
||
queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
|
||
enabled: isOpen && !!printerId,
|
||
});
|
||
|
||
// Fetch color catalog
|
||
const { data: colorCatalog } = useQuery({
|
||
queryKey: ['colorCatalog'],
|
||
queryFn: () => api.getColorCatalog(),
|
||
enabled: isOpen,
|
||
staleTime: Infinity,
|
||
});
|
||
|
||
// Backend Bambu printer-model registry — drives the @BBL short-code matcher
|
||
// and (here) the reverse short-code → long-name lookup that lets us check
|
||
// imported local presets' `compatible_printers` list against the slot's
|
||
// printer (#1623). Long staleTime: the registry only changes across backend
|
||
// releases.
|
||
const { data: printerModelsData } = useQuery({
|
||
queryKey: ['slicerPrinterModels'],
|
||
queryFn: api.getSlicerPrinterModels,
|
||
enabled: isOpen,
|
||
staleTime: Infinity,
|
||
});
|
||
|
||
const compatIndex = useMemo(
|
||
() => buildCompatibilityIndex(printerModelsData ?? {}),
|
||
[printerModelsData],
|
||
);
|
||
|
||
// The full printer-preset name for this slot's printer — e.g. the short
|
||
// code "X1C" resolves to "Bambu Lab X1 Carbon 0.4 nozzle" via the backend
|
||
// registry plus the slot's nozzle. Used to filter imported local presets
|
||
// whose `compatible_printers` list is keyed by the full slicer preset name.
|
||
// Null when the registry hasn't loaded or no model is known — caller skips
|
||
// the filter in that case (fail-open, same shape as the cloud-preset filter).
|
||
const fullPrinterName = useMemo<string | null>(() => {
|
||
if (!printerModel || !printerModelsData) return null;
|
||
for (const [longName, shortCode] of Object.entries(printerModelsData)) {
|
||
if (shortCode === printerModel) {
|
||
return `${longName.startsWith('Bambu Lab ') ? longName : `Bambu Lab ${longName}`} ${nozzleDiameter} nozzle`;
|
||
}
|
||
}
|
||
return null;
|
||
}, [printerModel, printerModelsData, nozzleDiameter]);
|
||
|
||
// Configure slot mutation
|
||
const configureMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!selectedPresetId) throw new Error('No filament preset selected');
|
||
|
||
// Determine preset source. Orca detection is done via setting_id
|
||
// lookup as well as the ``orca_`` prefix because the saved-preset
|
||
// pre-population on modal open (the useEffect at the top of this
|
||
// component) writes ``slotInfo.savedPresetId`` verbatim — which for a
|
||
// historical Orca save would be the raw UUID, no prefix. Falling
|
||
// through to the cloud lookup in that case would have thrown
|
||
// "Selected preset not found".
|
||
const isLocal = selectedPresetId.startsWith('local_');
|
||
const isBuiltin = selectedPresetId.startsWith('builtin_');
|
||
const orcaSettingId = selectedPresetId.startsWith('orca_')
|
||
? selectedPresetId.replace('orca_', '')
|
||
: selectedPresetId;
|
||
const orcaPresetCandidate = (!isLocal && !isBuiltin)
|
||
? orcaCloudList?.filament.find(p => p.setting_id === orcaSettingId)
|
||
: undefined;
|
||
const isOrca = !!orcaPresetCandidate;
|
||
const orcaPreset = orcaPresetCandidate ?? null;
|
||
const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null;
|
||
const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null;
|
||
const localPreset = isLocal
|
||
? localPresets?.filament.find(p => p.id === localId)
|
||
: null;
|
||
const builtinPreset = isBuiltin
|
||
? builtinFilaments?.find(b => b.filament_id === builtinFilamentId)
|
||
: null;
|
||
|
||
// Get the selected cloud preset details (null for local/builtin/orca presets)
|
||
const selectedPreset = (!isLocal && !isBuiltin && !isOrca)
|
||
? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId)
|
||
: null;
|
||
|
||
if (!isLocal && !isBuiltin && !isOrca && !selectedPreset) throw new Error('Selected preset not found');
|
||
if (isLocal && !localPreset) throw new Error('Selected local preset not found');
|
||
if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found');
|
||
if (isOrca && !orcaPreset) throw new Error('Selected Orca Cloud preset not found');
|
||
|
||
// Parse the preset name for filament info
|
||
const presetName = isLocal
|
||
? localPreset!.name
|
||
: isBuiltin
|
||
? builtinPreset!.name
|
||
: isOrca
|
||
? orcaPreset!.name
|
||
: selectedPreset!.name;
|
||
const parsed = parsePresetName(presetName);
|
||
|
||
// 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 = presetName.replace(/@.+$/, '').trim();
|
||
|
||
let trayInfoIdx: string;
|
||
let settingId: string;
|
||
|
||
// Parsed material from preset name — handles "Support for" patterns correctly.
|
||
// Prefer this over stored filament_type which may have been parsed with old logic.
|
||
const parsedMat = parsed.material.toUpperCase();
|
||
|
||
// Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for
|
||
// presets that don't carry a Bambu setting_id of their own (local
|
||
// imports and Orca Cloud sync both fall in this bucket). The printer's
|
||
// firmware needs SOMETHING in tray_info_idx to recognize the filament
|
||
// type for HMS / drying / colour-matching; the closest generic Bambu
|
||
// filament for the parsed material is the right choice.
|
||
const GENERIC_IDS: Record<string, string> = {
|
||
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
|
||
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
|
||
'ABS': 'GFB99', 'ASA': 'GFB98',
|
||
'PC': 'GFC99',
|
||
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
|
||
'TPU': 'GFU99',
|
||
'PVA': 'GFS99', 'HIPS': 'GFS98',
|
||
'PE': 'GFP99', 'PP': 'GFP97',
|
||
};
|
||
|
||
if (isLocal) {
|
||
// Local presets have no Bambu Cloud setting_id, but need a valid
|
||
// tray_info_idx for the printer to recognize the filament type.
|
||
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase();
|
||
// Try exact match first, then base material (strip suffixes like "-CF", "+", " HF")
|
||
trayInfoIdx = GENERIC_IDS[material]
|
||
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|
||
|| GENERIC_IDS[material.replace(/\+$/, '')]
|
||
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|
||
|| '';
|
||
settingId = '';
|
||
} else if (isOrca) {
|
||
// Orca Cloud presets have a UUID setting_id that Bambu printers can't
|
||
// resolve; treat them like local imports — derive a generic tray_info
|
||
// _idx from the parsed material, leave settingId empty so the slicer
|
||
// doesn't get a foreign cloud ID it can't look up.
|
||
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
|
||
trayInfoIdx = GENERIC_IDS[material]
|
||
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|
||
|| GENERIC_IDS[material.replace(/\+$/, '')]
|
||
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|
||
|| '';
|
||
settingId = '';
|
||
} else if (isBuiltin) {
|
||
// Built-in presets use the filament_id directly as tray_info_idx
|
||
trayInfoIdx = builtinFilamentId!;
|
||
settingId = '';
|
||
} else {
|
||
trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
|
||
settingId = selectedPresetId;
|
||
|
||
// User cloud presets may carry a distinct filament_id in the cloud detail
|
||
// (e.g. "P285e239"); prefer it when present. Never fall back to base_id —
|
||
// that collapses custom presets to the inherited generic's filament_id and
|
||
// makes the slicer resolve the slot to "Generic …" instead (#1053).
|
||
if (!selectedPresetId.startsWith('GFS')) {
|
||
try {
|
||
const detail = await api.getCloudSettingDetail(selectedPresetId);
|
||
if (detail.filament_id) {
|
||
trayInfoIdx = detail.filament_id;
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to fetch preset detail for filament_id:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Default temp range — use local preset core fields if available
|
||
let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190;
|
||
let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230;
|
||
|
||
if (!isLocal || isBuiltin || isOrca || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) {
|
||
// Fall back to material-based defaults (prefer parsed material for "Support for" handling)
|
||
const material = (isLocal
|
||
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.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 === 'PCTG') {
|
||
tempMin = 220;
|
||
tempMax = 260;
|
||
} 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;
|
||
|
||
// Determine tray_type: prefer parsed material from preset name (handles "Support for"
|
||
// patterns correctly) over stored filament_type which may have been parsed with old logic.
|
||
const trayType = isLocal
|
||
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA')
|
||
: isOrca
|
||
? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || 'PLA')
|
||
: (parsed.material || 'PLA');
|
||
|
||
// Configure the slot via MQTT
|
||
const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
|
||
tray_info_idx: trayInfoIdx,
|
||
tray_type: trayType,
|
||
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: settingId, // Full setting ID for slicer compatibility (empty for local)
|
||
// 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
|
||
const mappingPresetId = isLocal
|
||
? `local_${localId}`
|
||
: isBuiltin
|
||
? `builtin_${builtinFilamentId}`
|
||
: isOrca
|
||
? selectedPresetId
|
||
: selectedPresetId;
|
||
const mappingSource = isLocal
|
||
? 'local'
|
||
: isBuiltin
|
||
? 'builtin'
|
||
: isOrca
|
||
? 'orca_cloud'
|
||
: 'cloud';
|
||
try {
|
||
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
|
||
} 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
|
||
scheduleClose(() => {
|
||
setShowSuccess(false);
|
||
onClose();
|
||
}, 1500);
|
||
},
|
||
});
|
||
|
||
// Reset slot mutation
|
||
const resetMutation = useMutation({
|
||
mutationFn: async () => {
|
||
return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
|
||
},
|
||
onSuccess: () => {
|
||
setShowSuccess(true);
|
||
onSuccess?.();
|
||
scheduleClose(() => {
|
||
setShowSuccess(false);
|
||
onClose();
|
||
}, 1500);
|
||
},
|
||
});
|
||
|
||
// Unified preset item for the list (orca_cloud + cloud + local + builtin fallback)
|
||
type PresetItem = { id: string; name: string; source: 'orca_cloud' | 'cloud' | 'local' | 'builtin'; isUser: boolean };
|
||
|
||
// Filter filament presets based on search (merged orca_cloud + cloud + local + builtin)
|
||
const filteredPresets = useMemo(() => {
|
||
const query = searchQuery.toLowerCase();
|
||
const items: PresetItem[] = [];
|
||
|
||
// Collect IDs already covered by higher-priority tiers to avoid duplicates
|
||
const coveredIds = new Set<string>();
|
||
|
||
// Currently-configured preset should always be shown (bypass model filter)
|
||
const savedId = slotInfo.savedPresetId;
|
||
const trayIdx = slotInfo.trayInfoIdx;
|
||
|
||
// 0. Orca Cloud filament presets — surfaced first because the user
|
||
// explicitly opted into Orca sync; their picks should outrank Bambu Cloud
|
||
// presets of the same name. IDs are prefixed ``orca_`` so the configure
|
||
// flow can detect "this is an Orca preset" via a cheap string check
|
||
// (mirrors the ``local_`` / ``builtin_`` prefix convention already in use).
|
||
if (orcaCloudList?.filament) {
|
||
for (const op of orcaCloudList.filament) {
|
||
const orcaId = `orca_${op.setting_id}`;
|
||
coveredIds.add(op.setting_id);
|
||
coveredIds.add(orcaId);
|
||
if (query && !op.name.toLowerCase().includes(query)) continue;
|
||
if (printerModel) {
|
||
const presetModel = extractPresetModel(op.name, printerModelsData ?? {});
|
||
if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
|
||
}
|
||
// All Orca Cloud profiles are user-authored, so isUser is always true.
|
||
items.push({ id: orcaId, name: op.name, source: 'orca_cloud', isUser: true });
|
||
}
|
||
}
|
||
|
||
// 1. Cloud presets
|
||
if (cloudSettings?.filament) {
|
||
for (const cp of cloudSettings.filament) {
|
||
if (coveredIds.has(cp.setting_id)) continue;
|
||
coveredIds.add(cp.setting_id);
|
||
// Keep preset if it matches the slot's saved mapping or current tray_info_idx
|
||
const isSavedPreset = savedId === cp.setting_id;
|
||
const isCurrentPreset = isSavedPreset
|
||
|| (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
|
||
// Search filter applies to ALL presets (including saved) — no bypass
|
||
if (query && !cp.name.toLowerCase().includes(query)) continue;
|
||
// Filter by printer model if set (skip for current preset). Uses the
|
||
// alias-aware match so Bambu's "A1 Mini" → "A1M" cloud rename (#1649)
|
||
// doesn't hide A1 Mini cloud profiles.
|
||
if (!isCurrentPreset && printerModel) {
|
||
const presetModel = extractPresetModel(cp.name, printerModelsData ?? {});
|
||
if (presetModel && !matchesPrinterModelSuffix(presetModel, printerModel)) continue;
|
||
}
|
||
items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
|
||
}
|
||
}
|
||
|
||
// 2. Local presets — filter by the slicer's own ``compatible_printers``
|
||
// list when present (#1623). LocalPreset.compatible_printers is a JSON-
|
||
// encoded string array; presetCompatibility returns 'mismatch' when the
|
||
// list is set and our derived full printer name isn't in it, 'unknown'
|
||
// when neither the list nor an @BBL token is parseable — we hide on
|
||
// 'mismatch' only so user-imported presets without compatible_printers
|
||
// still surface (back-compat with the prior "always show" behaviour for
|
||
// hand-edited / lossily-imported presets).
|
||
if (localPresets?.filament) {
|
||
const savedLocalId = slotInfo.savedPresetId;
|
||
for (const lp of localPresets.filament) {
|
||
const localId = `local_${lp.id}`;
|
||
if (query && !lp.name.toLowerCase().includes(query)) continue;
|
||
const isCurrentPreset = savedLocalId === localId;
|
||
if (!isCurrentPreset && fullPrinterName) {
|
||
let compatList: string[] | null = null;
|
||
if (lp.compatible_printers) {
|
||
try {
|
||
const parsed = JSON.parse(lp.compatible_printers);
|
||
if (Array.isArray(parsed)) compatList = parsed.filter((s): s is string => typeof s === 'string');
|
||
} catch {
|
||
compatList = null;
|
||
}
|
||
}
|
||
const verdict = presetCompatibility(
|
||
{ name: lp.name, compatible_printers: compatList },
|
||
'filament',
|
||
fullPrinterName,
|
||
compatIndex,
|
||
);
|
||
if (verdict === 'mismatch') continue;
|
||
}
|
||
items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
|
||
}
|
||
}
|
||
|
||
// 3. Built-in filament names (fallback — only add entries not already covered)
|
||
if (builtinFilaments) {
|
||
for (const bf of builtinFilaments) {
|
||
if (coveredIds.has(bf.filament_id)) continue;
|
||
// Convert filament_id to setting_id format for cloud compatibility
|
||
// e.g. "GFA00" → cloud setting_id would be "GFSA00" (insert S after GF)
|
||
const settingId = bf.filament_id.startsWith('GF')
|
||
? 'GFS' + bf.filament_id.slice(2)
|
||
: bf.filament_id;
|
||
if (coveredIds.has(settingId)) continue;
|
||
if (!query || bf.name.toLowerCase().includes(query)) {
|
||
items.push({ id: `builtin_${bf.filament_id}`, name: bf.name, source: 'builtin', isUser: false });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sort: local first (user explicitly imported them), then orca_cloud,
|
||
// then bambu cloud, then builtin fallback. Matches the SliceModal
|
||
// tier priority.
|
||
return items.sort((a, b) => {
|
||
const sourceOrder = { local: 0, orca_cloud: 1, cloud: 2, builtin: 3 };
|
||
if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source];
|
||
if (a.isUser && !b.isUser) return -1;
|
||
if (!a.isUser && b.isUser) return 1;
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
}, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx, fullPrinterName, compatIndex, printerModelsData]);
|
||
|
||
// Get full preset name for K profile filtering (brand + material, without printer suffix)
|
||
const selectedPresetInfo = useMemo(() => {
|
||
if (!selectedPresetId) return null;
|
||
|
||
// Resolve the name from orca, cloud, local, or builtin presets. The
|
||
// Orca branch tolerates both ``orca_<UUID>`` and a bare UUID — see the
|
||
// configure-mutation comment for why the raw UUID also reaches us.
|
||
// ``filamentId`` is the bare Bambu filament_id (e.g. "GFG98") when the
|
||
// preset has one — used to id-match K-profiles directly without name
|
||
// parsing (#1688). Empty string for paths with no usable filament_id
|
||
// (orca presets, local presets), which makes the id-match branch skip.
|
||
let presetName: string | null = null;
|
||
let filamentId = '';
|
||
if (selectedPresetId.startsWith('local_')) {
|
||
const localId = parseInt(selectedPresetId.replace('local_', ''), 10);
|
||
const lp = localPresets?.filament.find(p => p.id === localId);
|
||
presetName = lp?.name || null;
|
||
} else if (selectedPresetId.startsWith('builtin_')) {
|
||
const builtinFilamentId = selectedPresetId.replace('builtin_', '');
|
||
const bf = builtinFilaments?.find(b => b.filament_id === builtinFilamentId);
|
||
presetName = bf?.name || null;
|
||
filamentId = toFilamentId(builtinFilamentId);
|
||
} else {
|
||
const orcaCandidateId = selectedPresetId.startsWith('orca_')
|
||
? selectedPresetId.replace('orca_', '')
|
||
: selectedPresetId;
|
||
const op = orcaCloudList?.filament.find(p => p.setting_id === orcaCandidateId);
|
||
if (op) {
|
||
presetName = op.name;
|
||
} else if (cloudSettings?.filament) {
|
||
const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
|
||
presetName = cp?.name || null;
|
||
if (cp) {
|
||
// SlicerSetting only carries setting_id ("GFSG98_09"); toFilamentId
|
||
// drops the variant suffix and the "S" infix to yield "GFG98".
|
||
filamentId = toFilamentId(cp.setting_id);
|
||
}
|
||
}
|
||
}
|
||
if (!presetName) {
|
||
return null;
|
||
}
|
||
|
||
// Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
|
||
let nameWithoutSuffix = presetName.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,
|
||
filamentId,
|
||
};
|
||
}, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]);
|
||
|
||
// For backwards compatibility with the label
|
||
const selectedMaterial = selectedPresetInfo?.fullName || '';
|
||
|
||
// Filter color catalog entries matching the selected preset's brand + material
|
||
const catalogColors = useMemo(() => {
|
||
if (!colorCatalog || !selectedPresetInfo) return [];
|
||
|
||
const { fullName, brand } = selectedPresetInfo;
|
||
|
||
// Try to find colors matching the full preset name (e.g., "PLA Metal")
|
||
// The catalog uses the variant as part of the material field (e.g., material="PLA Metal")
|
||
// Extract the full material+variant from the preset name
|
||
const materialVariant = fullName.replace(/^(Bambu\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\s*/i, '').trim();
|
||
|
||
return colorCatalog.filter(entry => {
|
||
const entryMaterial = (entry.material || '').toUpperCase();
|
||
const entryManufacturer = entry.manufacturer.toUpperCase();
|
||
|
||
// Match material: try full material+variant first, then just material type
|
||
const materialMatch = entryMaterial === materialVariant.toUpperCase()
|
||
|| entryMaterial.includes(materialVariant.toUpperCase())
|
||
|| materialVariant.toUpperCase().includes(entryMaterial);
|
||
|
||
if (!materialMatch) return false;
|
||
|
||
// If brand is present, also match manufacturer
|
||
if (brand) {
|
||
const upperBrand = brand.toUpperCase();
|
||
// Fuzzy match: "Bambu" matches "Bambu Lab", etc.
|
||
if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}, [colorCatalog, selectedPresetInfo]);
|
||
|
||
const matchingKProfiles = useMemo(() => {
|
||
if (!kprofilesData?.profiles) return [];
|
||
if (!selectedPresetInfo) {
|
||
// Assigned-but-unconfigured slot (filament loaded but the printer hasn't
|
||
// bound a preset yet: tray_type=""/tray_info_idx=""/no slot_preset_mappings
|
||
// row). The cali_idx safety net further down lives past the main name+id
|
||
// matcher and never runs from here, so surface the slot's currently-active
|
||
// K-profile directly so Configure Slot keeps showing it across reopen
|
||
// instead of dropping to default 0.020 (#1689 follow-up).
|
||
const activeIdx = slotInfo.caliIdx;
|
||
if (activeIdx != null && activeIdx > 0) {
|
||
const active = kprofilesData.profiles.find(
|
||
p => p.slot_id === activeIdx
|
||
&& (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
|
||
);
|
||
if (active) return [active];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
const { fullName, material, brand, filamentId } = selectedPresetInfo;
|
||
const upperFullName = fullName.toUpperCase();
|
||
const upperMaterial = material.toUpperCase();
|
||
// "Generic" leads every built-in Bambu preset name ("Generic PLA",
|
||
// "Generic PETG") but is not a manufacturer (#2710). Treating it as one
|
||
// put the filter into brand-gated mode and demanded "GENERIC" in the
|
||
// K-profile name, which no real profile has — so selecting a built-in
|
||
// generic preset matched nothing at all.
|
||
const upperBrand = brand.toUpperCase() === 'GENERIC' ? '' : brand.toUpperCase();
|
||
const presetFid = filamentId; // already normalised via toFilamentId
|
||
|
||
// 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 => {
|
||
// Preferred: exact filament_id match (#1688). A user's custom K-profile
|
||
// whose name doesn't agree with the slicer preset still surfaces when
|
||
// both sides agree on filament_id.
|
||
//
|
||
// Generic GFx99 ids count here too (#2710). The equality test already
|
||
// means both sides carry the *same* id, so the old "generic ids
|
||
// over-match" exclusion could only ever fire when the selected preset
|
||
// was itself the generic one — precisely the case where the match is
|
||
// right. The printer keeps one calibration table per filament_id, so a
|
||
// slot on "Generic PLA" should offer every profile calibrated under
|
||
// Generic PLA, whatever the user named them.
|
||
if (presetFid) {
|
||
const calFid = toFilamentId(p.filament_id);
|
||
if (calFid && calFid === presetFid) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
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 the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)
|
||
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 (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
|
||
// Replace with profile matching slot's extruder
|
||
seen.set(key, profile);
|
||
}
|
||
}
|
||
|
||
const result = Array.from(seen.values());
|
||
|
||
// Always include the slot's currently-active K-profile (by cali_idx / slot_id),
|
||
// even if its name and filament_id didn't match the selected preset (#1689).
|
||
// A spool assigned under "Generic PLA" can have a K-profile actively bound on
|
||
// the printer whose filament_id differs from "Generic PLA"; without this
|
||
// safety net the modal shows "not assigned, default 0.020" while the printer
|
||
// card's hover-card correctly shows the active profile.
|
||
const activeIdx = slotInfo.caliIdx;
|
||
if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
|
||
const active = kprofilesData.profiles.find(
|
||
p => p.slot_id === activeIdx
|
||
&& (slotInfo.extruderId === undefined || p.extruder_id === slotInfo.extruderId),
|
||
);
|
||
if (active) result.unshift(active);
|
||
}
|
||
|
||
return result;
|
||
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
|
||
|
||
// Every remaining K-profile the printer holds, offered under a separate group
|
||
// after the matching ones (#2710). The matcher works off preset names and
|
||
// filament ids, neither of which the user controls when they name a profile
|
||
// after its colour — so there is always a residual chance it filters out a
|
||
// profile the user wants. This makes that recoverable in the UI instead of
|
||
// sending them to the slicer: the printer's own calibration table is the
|
||
// authority on what can be selected, and the backend realigns the slot's
|
||
// filament context to whichever profile is picked.
|
||
const otherKProfiles = useMemo(() => {
|
||
if (!kprofilesData?.profiles) return [];
|
||
const matched = new Set(matchingKProfiles.map(p => kProfileOptionValue(p)));
|
||
// Same name+k_value dedup as the matching list, so a multi-nozzle printer's
|
||
// duplicate rows don't show up twice here either.
|
||
const seen = new Map<string, KProfile>();
|
||
for (const profile of kprofilesData.profiles) {
|
||
const key = kProfileOptionValue(profile);
|
||
if (matched.has(key)) continue;
|
||
const existing = seen.get(key);
|
||
if (!existing) {
|
||
seen.set(key, profile);
|
||
} else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
|
||
seen.set(key, profile);
|
||
}
|
||
}
|
||
return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||
}, [kprofilesData?.profiles, matchingKProfiles, slotInfo.extruderId]);
|
||
|
||
const hasAnyKProfile = matchingKProfiles.length > 0 || otherKProfiles.length > 0;
|
||
|
||
const selectKProfileByValue = useCallback((value: string) => {
|
||
if (!value) {
|
||
setSelectedKProfile(null);
|
||
return;
|
||
}
|
||
const profile = matchingKProfiles.find(p => kProfileOptionValue(p) === value)
|
||
|| otherKProfiles.find(p => kProfileOptionValue(p) === value)
|
||
|| null;
|
||
setSelectedKProfile(profile);
|
||
}, [matchingKProfiles, otherKProfiles]);
|
||
|
||
// Pre-select current profile when modal opens, reset when closes
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// Pre-populate from saved preset mapping (most reliable)
|
||
if (slotInfo.savedPresetId) {
|
||
setSelectedPresetId(slotInfo.savedPresetId);
|
||
} else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
|
||
// Fallback: try to match by tray_info_idx in cloud presets
|
||
// First try exact match on setting_id
|
||
let currentPreset = cloudSettings.filament.find(
|
||
p => p.setting_id === slotInfo.trayInfoIdx
|
||
);
|
||
// Then try matching by converting setting_id → filament_id format
|
||
if (!currentPreset) {
|
||
currentPreset = cloudSettings.filament.find(
|
||
p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx
|
||
);
|
||
}
|
||
if (currentPreset) {
|
||
setSelectedPresetId(currentPreset.setting_id);
|
||
}
|
||
} else if (slotInfo.trayInfoIdx && builtinFilaments?.length) {
|
||
// Last resort: match trayInfoIdx against builtin presets
|
||
const trayIdx = slotInfo.trayInfoIdx;
|
||
const match = builtinFilaments.find(bf => bf.filament_id === trayIdx);
|
||
if (match) {
|
||
setSelectedPresetId(`builtin_${match.filament_id}`);
|
||
}
|
||
}
|
||
|
||
// Pre-populate color from current slot (black is valid — empty slots don't pass trayColor)
|
||
if (slotInfo.trayColor) {
|
||
const hex = slotInfo.trayColor.slice(0, 6);
|
||
if (hex) {
|
||
setColorHex(hex);
|
||
}
|
||
}
|
||
} else {
|
||
// Reset when modal closes
|
||
setSelectedPresetId('');
|
||
setSelectedKProfile(null);
|
||
setColorHex('');
|
||
setColorInput('');
|
||
setSearchQuery('');
|
||
setShowSuccess(false);
|
||
scrolledToRef.current = '';
|
||
}
|
||
}, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]);
|
||
|
||
// Auto-select best matching K profile when preset changes
|
||
useEffect(() => {
|
||
if (matchingKProfiles.length > 0) {
|
||
// Prefer the currently-active K-profile (by cali_idx) if available
|
||
if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
|
||
const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);
|
||
if (active) {
|
||
setSelectedKProfile(active);
|
||
return;
|
||
}
|
||
}
|
||
// Fallback: first matching profile
|
||
setSelectedKProfile(matchingKProfiles[0]);
|
||
} else {
|
||
setSelectedKProfile(null);
|
||
}
|
||
}, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);
|
||
|
||
// 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]);
|
||
|
||
const isLoading = (settingsLoading && !cloudError) || localLoading || builtinLoading || kprofilesLoading;
|
||
|
||
// Scroll selected preset into view when data finishes loading or the selection changes.
|
||
// Uses a ref guard so scrollIntoView only fires once per selection, preventing the
|
||
// infinite scroll loop that occurred on Windows with inline callback refs.
|
||
useEffect(() => {
|
||
if (!isLoading && selectedPresetId && selectedPresetId !== scrolledToRef.current) {
|
||
const raf = requestAnimationFrame(() => {
|
||
const modal = document.querySelector('[class*="fixed inset-0 z-50"]');
|
||
const el = modal?.querySelector(`[data-preset-id="${CSS.escape(selectedPresetId)}"]`);
|
||
if (el) {
|
||
scrolledToRef.current = selectedPresetId;
|
||
el.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
});
|
||
return () => cancelAnimationFrame(raf);
|
||
}
|
||
}, [selectedPresetId, isLoading]);
|
||
|
||
if (!isOpen) return null;
|
||
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 ${fullScreen ? '' : 'items-center justify-center'}`}>
|
||
{/* Backdrop */}
|
||
{!fullScreen && (
|
||
<div
|
||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||
onClick={onClose}
|
||
/>
|
||
)}
|
||
|
||
{/* Modal */}
|
||
<div className={fullScreen
|
||
? 'relative w-full h-full bg-bambu-dark-secondary flex flex-col'
|
||
: '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 shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<Settings2 className="w-5 h-5 text-bambu-blue" />
|
||
<h2 className="text-lg font-semibold text-white">{t('configureAmsSlot.title')}</h2>
|
||
{/* Inline slot info in fullScreen mode */}
|
||
{fullScreen && (
|
||
<div className="flex items-center gap-2 ml-4 text-sm text-bambu-gray">
|
||
<span className="text-white/30">|</span>
|
||
{slotInfo.trayColor && (
|
||
<span
|
||
className="w-4 h-4 rounded-full border border-black/20"
|
||
style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
|
||
/>
|
||
)}
|
||
<span className="text-white/70">
|
||
{t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
|
||
</span>
|
||
{slotInfo.traySubBrands && (
|
||
<span>({slotInfo.traySubBrands})</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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 overflow-y-auto ${fullScreen ? 'flex-1 min-h-0' : 'space-y-4 max-h-[60vh]'}`}>
|
||
{/* 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">{t('configureAmsSlot.slotConfigured')}</p>
|
||
<p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Slot info */}
|
||
{!fullScreen && (
|
||
<div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
|
||
<p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
|
||
<div className="flex items-center gap-2">
|
||
{slotInfo.trayColor && (
|
||
<span
|
||
className="w-4 h-4 rounded-full border border-black/20"
|
||
style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
|
||
/>
|
||
)}
|
||
<span className="text-white font-medium">
|
||
{t('configureAmsSlot.slotLabel', { ams: 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>
|
||
) : fullScreen ? (
|
||
/* Two-column layout for kiosk display */
|
||
<div className="flex gap-4 h-full">
|
||
{/* Left column: Filament preset list (takes full height) */}
|
||
<div className="w-1/2 flex flex-col min-h-0">
|
||
<label className="block text-sm text-bambu-gray mb-2">
|
||
{t('configureAmsSlot.filamentProfile')} <span className="text-red-600 dark:text-red-400">*</span>
|
||
</label>
|
||
<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 shrink-0"
|
||
/>
|
||
<div className="flex-1 min-h-0 overflow-y-auto space-y-1">
|
||
{filteredPresets.length === 0 ? (
|
||
<p className="text-center py-4 text-bambu-gray">
|
||
{(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
|
||
? t('configureAmsSlot.noPresetsAvailable')
|
||
: t('configureAmsSlot.noMatchingPresets')}
|
||
</p>
|
||
) : (
|
||
filteredPresets.map((preset) => (
|
||
<button
|
||
key={preset.id}
|
||
data-preset-id={preset.id}
|
||
onClick={() => setSelectedPresetId(preset.id)}
|
||
className={`group w-full p-2 rounded-lg border text-left transition-colors ${
|
||
selectedPresetId === preset.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 group-hover:whitespace-normal group-hover:break-all" title={preset.name}>{preset.name}</span>
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{preset.source === 'local' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
|
||
{t('profiles.localProfiles.badge')}
|
||
</span>
|
||
)}
|
||
{preset.source === 'orca_cloud' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400">
|
||
{t('configureAmsSlot.orcaCloud')}
|
||
</span>
|
||
)}
|
||
{preset.source === 'cloud' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
|
||
{t('configureAmsSlot.bambuCloud')}
|
||
</span>
|
||
)}
|
||
{preset.source === 'builtin' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
|
||
{t('configureAmsSlot.builtin')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right column: K Profile + Color */}
|
||
<div className="w-1/2 flex flex-col gap-4 min-h-0 overflow-y-auto">
|
||
{/* K Profile Select */}
|
||
<div>
|
||
<label className="block text-sm text-bambu-gray mb-2">
|
||
{t('configureAmsSlot.kProfileLabel')}
|
||
{selectedMaterial && (
|
||
<span className="ml-2 text-xs text-bambu-blue">
|
||
{t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
|
||
</span>
|
||
)}
|
||
</label>
|
||
{hasAnyKProfile ? (
|
||
<div className="relative">
|
||
<select
|
||
value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
|
||
onChange={(e) => selectKProfileByValue(e.target.value)}
|
||
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="">{t('configureAmsSlot.noKProfile')}</option>
|
||
{matchingKProfiles.map((profile) => (
|
||
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
|
||
{profile.name} (K={profile.k_value})
|
||
</option>
|
||
))}
|
||
{otherKProfiles.length > 0 && (
|
||
<optgroup label={t('configureAmsSlot.otherKProfiles')}>
|
||
{otherKProfiles.map((profile) => (
|
||
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
|
||
{profile.name} (K={profile.k_value})
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
</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">
|
||
{t('configureAmsSlot.noMatchingKProfiles')}
|
||
</p>
|
||
) : (
|
||
<span className="inline-block text-xs px-2 py-1 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-500/30">
|
||
{t('configureAmsSlot.selectFilamentFirst')}
|
||
</span>
|
||
)}
|
||
{selectedKProfile && (
|
||
<p className="text-xs text-bambu-green mt-1">
|
||
{t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Custom color */}
|
||
<div>
|
||
<label className="block text-sm text-bambu-gray mb-2">
|
||
{t('configureAmsSlot.customColorLabel')}
|
||
</label>
|
||
{catalogColors.length > 0 && (
|
||
<div className="mb-3">
|
||
<p className="text-xs text-bambu-gray mb-1.5">
|
||
{t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
|
||
</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{catalogColors.map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
onClick={() => {
|
||
const hex = entry.hex_color.replace('#', '').toUpperCase();
|
||
setColorHex(hex);
|
||
setColorInput(entry.color_name);
|
||
}}
|
||
className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
|
||
colorHex === entry.hex_color.replace('#', '').toUpperCase()
|
||
? 'border-bambu-green scale-105'
|
||
: 'border-white/20 hover:border-white/40'
|
||
}`}
|
||
title={entry.color_name}
|
||
>
|
||
<span
|
||
className="w-4 h-4 rounded-full border border-black/20 flex-shrink-0"
|
||
style={{ backgroundColor: entry.hex_color }}
|
||
/>
|
||
<span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<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 ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
|
||
>
|
||
{showExtendedColors ? '−' : '+'}
|
||
</button>
|
||
</div>
|
||
{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>
|
||
)}
|
||
<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);
|
||
const nameHex = colorNameToHex(input);
|
||
if (nameHex) {
|
||
setColorHex(nameHex);
|
||
} else {
|
||
const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
|
||
if (cleaned.length === 6) {
|
||
setColorHex(cleaned);
|
||
} else if (cleaned.length === 3) {
|
||
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')}
|
||
>
|
||
{t('configureAmsSlot.clear')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{colorHex && (
|
||
<p className="text-xs text-bambu-gray mt-1.5">
|
||
{t('configureAmsSlot.hexLabel', { hex: colorHex })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Filament Profile Select */}
|
||
<div>
|
||
<label className="block text-sm text-bambu-gray mb-2">
|
||
{t('configureAmsSlot.filamentProfile')} <span className="text-red-600 dark: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 && !localPresets?.filament?.length)
|
||
? t('configureAmsSlot.noPresetsAvailable')
|
||
: t('configureAmsSlot.noMatchingPresets')}
|
||
</p>
|
||
) : (
|
||
filteredPresets.map((preset) => (
|
||
<button
|
||
key={preset.id}
|
||
data-preset-id={preset.id}
|
||
onClick={() => setSelectedPresetId(preset.id)}
|
||
className={`group w-full p-2 rounded-lg border text-left transition-colors ${
|
||
selectedPresetId === preset.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 group-hover:whitespace-normal group-hover:break-all" title={preset.name}>{preset.name}</span>
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{preset.source === 'local' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
|
||
{t('profiles.localProfiles.badge')}
|
||
</span>
|
||
)}
|
||
{preset.source === 'orca_cloud' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400">
|
||
{t('configureAmsSlot.orcaCloud')}
|
||
</span>
|
||
)}
|
||
{preset.source === 'cloud' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
|
||
{t('configureAmsSlot.bambuCloud')}
|
||
</span>
|
||
)}
|
||
{preset.source === 'builtin' && (
|
||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
|
||
{t('configureAmsSlot.builtin')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* K Profile Select */}
|
||
<div>
|
||
<label className="block text-sm text-bambu-gray mb-2">
|
||
{t('configureAmsSlot.kProfileLabel')}
|
||
{selectedMaterial && (
|
||
<span className="ml-2 text-xs text-bambu-blue">
|
||
{t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
|
||
</span>
|
||
)}
|
||
</label>
|
||
{hasAnyKProfile ? (
|
||
<div className="relative">
|
||
<select
|
||
value={selectedKProfile ? kProfileOptionValue(selectedKProfile) : ''}
|
||
onChange={(e) => selectKProfileByValue(e.target.value)}
|
||
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="">{t('configureAmsSlot.noKProfile')}</option>
|
||
{matchingKProfiles.map((profile) => (
|
||
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
|
||
{profile.name} (K={profile.k_value})
|
||
</option>
|
||
))}
|
||
{otherKProfiles.length > 0 && (
|
||
<optgroup label={t('configureAmsSlot.otherKProfiles')}>
|
||
{otherKProfiles.map((profile) => (
|
||
<option key={kProfileOptionValue(profile)} value={kProfileOptionValue(profile)}>
|
||
{profile.name} (K={profile.k_value})
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
)}
|
||
</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">
|
||
{t('configureAmsSlot.noMatchingKProfiles')}
|
||
</p>
|
||
) : (
|
||
<span className="inline-block text-xs px-2 py-1 rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-500/30">
|
||
{t('configureAmsSlot.selectFilamentFirst')}
|
||
</span>
|
||
)}
|
||
{selectedKProfile && (
|
||
<p className="text-xs text-bambu-green mt-1">
|
||
{t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Optional: Custom color */}
|
||
<div>
|
||
<label className="block text-sm text-bambu-gray mb-2">
|
||
{t('configureAmsSlot.customColorLabel')}
|
||
</label>
|
||
{/* Catalog colors matching selected preset */}
|
||
{catalogColors.length > 0 && (
|
||
<div className="mb-3">
|
||
<p className="text-xs text-bambu-gray mb-1.5">
|
||
{t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
|
||
</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{catalogColors.map((entry) => (
|
||
<button
|
||
key={entry.id}
|
||
onClick={() => {
|
||
const hex = entry.hex_color.replace('#', '').toUpperCase();
|
||
setColorHex(hex);
|
||
setColorInput(entry.color_name);
|
||
}}
|
||
className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
|
||
colorHex === entry.hex_color.replace('#', '').toUpperCase()
|
||
? 'border-bambu-green scale-105'
|
||
: 'border-white/20 hover:border-white/40'
|
||
}`}
|
||
title={entry.color_name}
|
||
>
|
||
<span
|
||
className="w-4 h-4 rounded-full border border-black/20 flex-shrink-0"
|
||
style={{ backgroundColor: entry.hex_color }}
|
||
/>
|
||
<span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* 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 ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
|
||
>
|
||
{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')}
|
||
>
|
||
{t('configureAmsSlot.clear')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{colorHex && (
|
||
<p className="text-xs text-bambu-gray mt-1.5">
|
||
{t('configureAmsSlot.hexLabel', { hex: colorHex })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex justify-between p-4 border-t border-bambu-dark-tertiary shrink-0">
|
||
{/* Reset button on the left */}
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => resetMutation.mutate()}
|
||
disabled={resetMutation.isPending || configureMutation.isPending}
|
||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10"
|
||
>
|
||
{resetMutation.isPending ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
{t('configureAmsSlot.resetting')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<RotateCcw className="w-4 h-4" />
|
||
{t('configureAmsSlot.resetSlot')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
{/* Cancel and Configure buttons on the right */}
|
||
<div className="flex gap-2">
|
||
<Button variant="secondary" onClick={onClose}>
|
||
{t('configureAmsSlot.cancel')}
|
||
</Button>
|
||
<Button
|
||
onClick={() => configureMutation.mutate()}
|
||
disabled={!canSave}
|
||
>
|
||
{configureMutation.isPending ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
{t('configureAmsSlot.configuring')}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Settings2 className="w-4 h-4" />
|
||
{t('configureAmsSlot.configureSlot')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error */}
|
||
{(configureMutation.isError || resetMutation.isError) && (
|
||
<div className="mx-4 mb-4 p-2 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded text-sm text-red-700 dark:text-red-400">
|
||
{(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|