import { useState, useRef, useEffect, useLayoutEffect, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Droplets, Copy, Check, Settings2, Package, Unlink } from 'lucide-react'; import { isLightColor, resolveSpoolColorName } from '../utils/colors'; import { buildFilamentBackground, parseStops } from './filamentSwatchHelpers'; interface FilamentData { vendor: 'Bambu Lab' | 'Generic'; profile: string; /** Catalogue name for the loaded colour. Callers that know the material * should resolve it with ``getColorName(hex, tray_sub_brands)`` -- a white * Matte spool is Ivory White, not the Jade White that shares its hex * (#2875). An assigned spool overrides this; see ``displayColorName``. */ colorName: string; colorHex: string | null; kFactor: string; fillLevel: number | null; // null = unknown trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking tagUid?: string | null; // Generic NFC tag UID fallback for linking fillSource?: 'ams' | 'spoolman' | 'inventory'; // Source of fill level data } interface SpoolmanConfig { enabled: boolean; onLinkSpool?: () => void; onUnlinkSpool?: () => void; linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link) syncMode?: string | null; // If auto-sync is enabled, we may want to hide the unlink option for Bambu spools } interface InventoryConfig { onAssignSpool?: () => void; onUnassignSpool?: () => void; // `subtype` is part of the spool's name, not decoration: "PLA" and "PLA Wood" // are different filaments, and a card that prints only the material tells the // user their wood-filled roll is plain PLA (the display-side half of #2902). // `rgba` / `extra_colors` / `effect_type` are the spool's own swatch. The // slot's telemetry colour is a single hex and can never describe a gradient // or a surface effect, so a tri-colour roll read as one flat band (#2967). assignedSpool?: { id: number; material: string; subtype: string | null; brand: string | null; color_name: string | null; rgba?: string | null; extra_colors?: string | null; effect_type?: string | null; remainingWeightGrams?: number | null; } | null; isAssigned?: boolean; } interface ConfigureSlotConfig { enabled: boolean; onConfigure?: () => void; } interface FilamentHoverCardProps { data: FilamentData; children: ReactNode; disabled?: boolean; className?: string; spoolman?: SpoolmanConfig; inventory?: InventoryConfig; configureSlot?: ConfigureSlotConfig; actions?: ReactNode; } /** * A hover card that displays filament details when hovering over AMS slots. * Replaces the basic browser tooltip with a styled popover. */ export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot, actions }: FilamentHoverCardProps) { const { t } = useTranslation(); const navigate = useNavigate(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); // Screen-space coordinates for the portaled card (#1336 follow-up). Using // a portal + position:fixed lets the popover escape sibling printer cards // that create their own stacking contexts on the dashboard — without this, // a card later in DOM order draws over the hover popover regardless of // z-index because z-index doesn't cross stacking-context boundaries. const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); const [copied, setCopied] = useState(false); const [showUnlinkConfirm, setShowUnlinkConfirm] = useState(false); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); const handleCopyUuid = () => { const uuid = data.trayUuid; if (!uuid) return; // Try modern clipboard API first, fallback to execCommand if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(uuid).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }).catch(() => { // Fallback on error fallbackCopy(uuid); }); } else { fallbackCopy(uuid); } }; const fallbackCopy = (text: string) => { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { console.error('Failed to copy to clipboard'); } document.body.removeChild(textarea); }; // Compute placement (top/bottom) + screen coordinates for the portaled // card. Runs on visibility change, scroll, and resize so the popover // tracks the trigger when the viewport moves. useLayoutEffect rather // than useEffect so the first paint already has the correct coords — // avoids a one-frame flicker at (0, 0). useLayoutEffect(() => { if (!isVisible) { setCoords(null); return; } const compute = () => { if (!triggerRef.current || !cardRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; const cardWidth = cardRef.current.offsetWidth; const headerHeight = 56; const spaceAbove = triggerRect.top - headerHeight; const spaceBelow = window.innerHeight - triggerRect.bottom; const placement: 'top' | 'bottom' = spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove ? 'bottom' : 'top'; const centerX = triggerRect.left + triggerRect.width / 2; const left = Math.max(8, Math.min(centerX - cardWidth / 2, window.innerWidth - cardWidth - 8)); const top = placement === 'top' ? triggerRect.top - cardHeight - 8 : triggerRect.bottom + 8; setPosition(placement); setCoords({ top, left }); }; // First compute is synchronous from the layout effect; a follow-up rAF // re-measures after the card actually has its rendered dimensions. compute(); const rafId = requestAnimationFrame(compute); window.addEventListener('scroll', compute, true); window.addEventListener('resize', compute); return () => { cancelAnimationFrame(rafId); window.removeEventListener('scroll', compute, true); window.removeEventListener('resize', compute); }; }, [isVisible]); const handleMouseEnter = () => { if (disabled) return; if (timeoutRef.current) clearTimeout(timeoutRef.current); // Small delay to prevent flicker on quick mouse movements timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 100); }; // Dismiss the card immediately, for actions that open a dialog or navigate away. // // The card is portaled at z-[60] so it can escape sibling printer cards' stacking // contexts, which puts it ABOVE ConfigureAmsSlotModal and LinkSpoolModal at z-50 — // so a card left standing draws over the very dialog it just opened. Mouseleave is // the only thing that normally hides it, and a touch device never sends one after // the tap that opened the card, so on a tablet it stays up indefinitely (#2631). // Clearing the timeout is not optional: a pending show timer would re-open it. const dismiss = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setIsVisible(false); }; // Cleanup timeout on unmount useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); // Get fill bar color based on percentage const getFillColor = (fill: number): string => { if (fill <= 15) return '#ef4444'; // red if (fill <= 30) return '#f97316'; // orange if (fill <= 50) return '#eab308'; // yellow return '#22c55e'; // green }; const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null; // An assigned spool outranks any hex lookup: it is the roll the user put in // this slot, named by whoever created it, and it is already shown two rows // below under ASSIGNED. Passing a null rgba keeps the helper to its // readable-name test -- a Bambu internal code like "A06-D0" is not a name // (#857) and must not displace the catalogue answer, which the caller has // already resolved with the slot's material (#2875). // Trimmed, so a spool saved with a whitespace-only colour name leaves the // swatch reading the catalogue answer instead of reading blank. const assignedColorName = resolveSpoolColorName(inventory?.assignedSpool?.color_name ?? null, null)?.trim(); const displayColorName = assignedColorName || data.colorName; const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null; // The header paints the spool's own swatch whenever the spool describes more // than one colour, or any surface effect (#2967). Telemetry cannot: a tray // record carries a single `tray_color` hex, so a Tri Color roll of yellow, // cyan and pink read as one flat band of whichever hex the slot was // configured with. // // Only when the spool actually says something extra. A plain single-colour // spool keeps the flat `backgroundColor` it has always had, so the common // case is untouched and the gradient machinery cannot regress it. const assignedSwatch = inventory?.assignedSpool ?? null; const swatchStops = parseStops(assignedSwatch?.extra_colors); const hasSwatchEffect = Boolean(assignedSwatch?.effect_type); // Any stop at all, not just two: `buildColorLayer` ignores `rgba` the moment // stops exist, so a one-stop spool renders that stop's colour and not the // slot hex. Honouring it here is what keeps this header agreeing with the // Inventory swatch, which is the whole point of sharing the builder. const useSpoolSwatch = Boolean(assignedSwatch) && (swatchStops.length > 0 || hasSwatchEffect); // Built from the spool end to end when used. Mixing the spool's stops over // the slot's base hex would render a gradient the user never configured if // the two ever disagreed. const spoolSwatchStyle = useSpoolSwatch ? buildFilamentBackground({ effectSize: 'card', rgba: assignedSwatch?.rgba ?? colorHex, extraColors: assignedSwatch?.extra_colors, effectType: assignedSwatch?.effect_type, subtype: assignedSwatch?.subtype, }) : null; // A single hex cannot decide legibility across several bands, and the header // label sits dead centre where a multi-stop background is most likely to // change under it. So a genuinely multi-band swatch puts the name on the same // scrim the vendor badge already uses rather than betting on one of the // stops. One stop, or an effect over one colour, leaves the base colour // intact -- the contrast test still has a real answer there, and scrimming // every effect spool would put a black pill on cards that never needed one. const swatchNeedsScrim = swatchStops.length > 1; // Which colour the contrast test should actually run against. Not always the // slot hex any more: once the spool's swatch is painted, a single stop // replaces the base entirely, and an effect-only spool paints the spool's own // rgba rather than the slot's. Testing the slot hex in either case would pick // the text colour for a background that is no longer on screen. const contrastBaseHex = useSpoolSwatch ? (swatchStops.length === 1 ? swatchStops[0] : assignedSwatch?.rgba ?? colorHex) : colorHex; return (
{children} {/* Portaled hover card — rendered into document.body so it escapes any ancestor stacking context. Sibling printer cards on the dashboard create their own stacking contexts; without the portal the popover gets covered by the next card even at z-[60] (#1336 follow-up). */} {isVisible && createPortal(
{/* Card container */}
{/* Color swatch header - the hero element */}
{/* Subtle gradient overlay for depth */}
{/* Color name on swatch */}
{displayColorName}
{/* Vendor badge - solid background for visibility on any color */}
{data.vendor === 'Bambu Lab' ? 'BBL' : 'GEN'}
{/* Details section */}
{/* Profile name */}
{t('ams.profile')} {data.profile}
{/* K Factor */}
{t('ams.kFactor')} {data.kFactor}
{/* Fill Level */}
{t('ams.fill')} {data.fillLevel !== null ? `${data.fillLevel}%` : '—'} {assignedRemainingWeight !== null && data.fillLevel !== null && ( • {assignedRemainingWeight}g )}
{/* Fill bar */}
{data.fillLevel !== null ? (
) : (
)}
{/* Spoolman section - only show if enabled */} {spoolman?.enabled && (
{/* Tray UUID with copy button */}
{t('spoolman.spoolId')} {data.trayUuid ? ( ) : ( )}
{/* Open in inventory button (when already linked to a Spoolman spool) */} {spoolman.linkedSpoolId && ( <> )} {/* Link/Unlink action buttons intentionally NOT rendered here. The inventory section below already provides Assign/Unassign for slot-binding (the primary user flow in Spoolman mode). Showing the spoolman tag-link buttons in addition surfaced two red Unlink-icon buttons for what users perceive as the same action, regardless of whether the labels said "Unlink Spool" vs "Unassign Spool". Tag-linking remains available via dedicated UI (LinkSpoolModal can be opened from Spoolman settings / inventory page). */}
)} {/* Inventory section — shown for every vendor including Bambu Lab (#1133). The earlier "non-Bambu only" gate prevented users from manually assigning a Bambu spool in inventory to an AMS slot when they didn't want to re-scan via SpoolBuddy NFC. */} {inventory && (
{inventory.assignedSpool ? ( <>
{t('inventory.assigned')}

{inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''} {inventory.assignedSpool.material} {inventory.assignedSpool.subtype ? ` ${inventory.assignedSpool.subtype}` : ''} {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}

#{inventory.assignedSpool.id}
{(!spoolman?.linkedSpoolId || inventory.assignedSpool!.id !== spoolman.linkedSpoolId) && ( )} {inventory.onUnassignSpool && ( )} ) : inventory.onAssignSpool ? ( ) : null}
)} {/* Configure slot section - always show if enabled */} {configureSlot?.enabled && (
)} {actions && (
{actions}
)}
{/* Arrow pointer */}
, document.body, )} {/* Unlink Confirmation Dialog */} {showUnlinkConfirm && (
setShowUnlinkConfirm(false)}>
e.stopPropagation()} >

{t('spoolman.unlinkConfirmTitle')}

{t('spoolman.unlinkConfirmMessage')}

)}
); } interface EmptySlotHoverCardProps { children: ReactNode; className?: string; configureSlot?: ConfigureSlotConfig; onAssignSpool?: () => void; actions?: ReactNode; // #1322 follow-up: distinguish firmware-confirmed empty (state 9/10) from // a user reset where the firmware still has a spool registered. "reset" // surfaces the user-cleared label; undefined / "physical" keeps the // historical "Empty slot" wording. kind?: 'physical' | 'reset'; } export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, actions, kind }: EmptySlotHoverCardProps) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); // Screen-space coords for the portaled card — same pattern as // FilamentHoverCard, see comment there (#1336 follow-up). const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); const triggerRef = useRef(null); const cardRef = useRef(null); const timeoutRef = useRef | null>(null); const handleMouseEnter = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(true), 80); }; const handleMouseLeave = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => setIsVisible(false), 100); }; // See FilamentHoverCard.dismiss — same z-[60]-over-a-z-50-dialog problem, and the // same missing mouseleave on touch (#2631). const dismiss = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); setIsVisible(false); }; useEffect(() => { return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, []); useLayoutEffect(() => { if (!isVisible) { setCoords(null); return; } const compute = () => { if (!triggerRef.current || !cardRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; const cardWidth = cardRef.current.offsetWidth; const centerX = triggerRect.left + triggerRect.width / 2; const left = Math.max(8, Math.min(centerX - cardWidth / 2, window.innerWidth - cardWidth - 8)); const top = triggerRect.top - cardHeight - 8; setCoords({ top, left }); }; compute(); const rafId = requestAnimationFrame(compute); window.addEventListener('scroll', compute, true); window.addEventListener('resize', compute); return () => { cancelAnimationFrame(rafId); window.removeEventListener('scroll', compute, true); window.removeEventListener('resize', compute); }; }, [isVisible]); return (
{children} {isVisible && createPortal(
{kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
{/* Configure slot button */} {(configureSlot?.enabled || onAssignSpool || actions) && (
{/* Assign before Configure, matching the filled-slot card above (#2791). The two cards are separate render paths and had drifted into opposite orders, so the menu reshuffled itself depending on whether the slot happened to hold filament. */} {onAssignSpool && ( )} {configureSlot?.enabled && ( )} {actions && (
{actions}
)}
)}
, document.body, )}
); }