Files
bambuddy/frontend/src/components/ConfigureAmsSlotModal.tsx
T
maziggy af282b3527 fix(kprofiles): populate the filament picker from all preset tiers (issue #2719)
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.
2026-08-01 09:36:42 +02:00

1779 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo, useEffect, useCallback, 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>
);
}