import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api, type PresetRef, type PresetSource, type SliceBundleSpec, type SliceJobProgress, type SliceRequest, type SlicerBundle, type SlicerCloudStatus, type UnifiedPreset, type UnifiedPresetsBySlot, type UnifiedPresetsResponse, } from '../api/client'; import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext'; import { useToast } from '../contexts/ToastContext'; import { PlatePickerModal } from './PlatePickerModal'; import type { PlateFilament } from '../types/plates'; import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers'; export type SliceSource = | { kind: 'libraryFile'; id: number; filename: string } | { kind: 'archive'; id: number; filename: string }; interface SliceModalProps { source: SliceSource; onClose: () => void; } type Slot = 'printer' | 'process' | 'filament'; // SliceModal-specific tier priority: local (imported) → cloud → standard. // Imported profiles are surfaced first because they're the user's curated // picks (often colour/type-tagged), cloud is second since names alone can't // drive metadata-aware match, standard is the bundled fallback. This is // distinct from the listing endpoint's dedup order and only affects what // the SliceModal renders / pre-picks. const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const; function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null { for (const tier of SLICE_MODAL_TIER_ORDER) { const list = by[tier][slot]; if (list.length > 0) { return { source: list[0].source, id: list[0].id }; } } return null; } const TIER_BONUS: Record = { local: 1.5, cloud: 1.0, standard: 0.5, }; function pickFilamentForSlot( by: UnifiedPresetsResponse, required: { type: string; color: string }, ): PresetRef | null { // Score every filament preset against the plate slot's required (type, // colour) and pick the highest. Mirrors the AMS slot-mapping match in the // print/schedule modal: type match dominates, exact-colour-match bumps over // similar-colour-match, and a small per-tier bonus breaks ties so cloud // user customisations win over standard bundled fallbacks of equal merit. const reqType = required.type.trim().toUpperCase(); const reqColor = normalizeColorForCompare(required.color); let best: { ref: PresetRef; score: number } | null = null; for (const tier of SLICE_MODAL_TIER_ORDER) { for (const p of by[tier].filament) { let score = 0; const presetType = (p.filament_type ?? '').trim().toUpperCase(); const presetColor = normalizeColorForCompare(p.filament_colour ?? ''); if (reqType && presetType && reqType === presetType) score += 10; if (reqColor && presetColor) { if (presetColor === reqColor) score += 5; else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2; } score += TIER_BONUS[tier]; if (best == null || score > best.score) { best = { ref: { source: p.source, id: p.id }, score }; } } } // Fall back to plain priority pick if every preset scored 0+tier (i.e. no // metadata matched). The fallback is exactly the single-color default — // first preset in the highest-priority non-empty tier. if (best == null) return pickDefault(by, 'filament'); return best.ref; } function toRefValue(ref: PresetRef | null): string { // The HTML ` onChange(e.target.value === '' ? null : e.target.value)} disabled={disabled} className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50" > {BED_TYPE_OPTIONS.map((opt) => ( ))} ); } interface PresetDropdownProps { label: string; slot: Slot; data: UnifiedPresetsResponse; value: PresetRef | null; onChange: (ref: PresetRef | null) => void; disabled?: boolean; // Optional colour swatch shown next to the label — used for multi-color // filament slots so the user can see at a glance which slot they're // configuring against the source 3MF's per-slot colour. swatchColor?: string; } function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor }: PresetDropdownProps) { const { t } = useTranslation(); const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => { // Order matches SLICE_MODAL_TIER_ORDER: imported first, then cloud, then // standard fallback. Sections with no entries collapse out so a user // without cloud / local presets only sees the tiers they actually have. const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [ { key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' }, { key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' }, { key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' }, ]; return tiers .map(({ key, label: lk, fallback }) => ({ tierLabel: t(lk, fallback), entries: (data[key] as UnifiedPresetsBySlot)[slot], })) .filter((s) => s.entries.length > 0); }, [data, slot, t]); const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0); return ( ); } // Top-of-modal bundle picker. The "None" option leaves the user on the // cloud/local/standard tier path; selecting a bundle id flips the modal // into bundle dispatch mode (see SliceModal state above). interface BundlePickerProps { bundles: SlicerBundle[]; selectedId: string | null; onChange: (id: string | null) => void; disabled?: boolean; } function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) { const { t } = useTranslation(); return ( ); } // Plain-string dropdown used for bundle-mode process / filament selectors. // Bundles store presets as a flat list of names within their printer-tied // directory, so a ` onChange(e.target.value || null)} disabled={disabled || options.length === 0} className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50" > {options.map((name) => ( ))} ); }