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 } from '../utils/colors'; interface FilamentData { vendor: 'Bambu Lab' | 'Generic'; profile: string; 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; assignedSpool?: { id: number; material: string; brand: string | null; color_name: 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; } /** * 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 }: 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); }; // 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; const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null; 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 */}
{data.colorName}
{/* 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.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}

{(!spoolman?.linkedSpoolId || inventory.assignedSpool!.id !== spoolman.linkedSpoolId) && ( )} {inventory.onUnassignSpool && ( )} ) : inventory.onAssignSpool ? ( ) : null}
)} {/* Configure slot section - always show if enabled */} {configureSlot?.enabled && (
)}
{/* 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; } export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool }: 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); }; 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(
{t('ams.emptySlot')}
{/* Configure slot button */} {(configureSlot?.enabled || onAssignSpool) && (
{configureSlot?.enabled && ( )} {onAssignSpool && ( )}
)}
, document.body, )}
); }