import { useState, useRef, useEffect, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react'; 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 fillSource?: 'ams' | 'spoolman'; // Source of fill level data } interface SpoolmanConfig { enabled: boolean; onLinkSpool?: (trayUuid: string) => void; hasUnlinkedSpools?: boolean; // Whether there are spools available to link linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link) } interface ConfigureSlotConfig { enabled: boolean; onConfigure?: () => void; } interface FilamentHoverCardProps { data: FilamentData; children: ReactNode; disabled?: boolean; className?: string; spoolman?: SpoolmanConfig; 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, configureSlot }: FilamentHoverCardProps) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const [copied, setCopied] = 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); }; // Calculate position when showing useEffect(() => { if (isVisible && triggerRef.current && cardRef.current) { const triggerRect = triggerRef.current.getBoundingClientRect(); const cardHeight = cardRef.current.offsetHeight; // Account for fixed header (56px) - space above should exclude header area const headerHeight = 56; const spaceAbove = triggerRect.top - headerHeight; const spaceBelow = window.innerHeight - triggerRect.bottom; // Prefer top, but flip to bottom if not enough space (accounting for header) if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) { setPosition('bottom'); } else { setPosition('top'); } } }, [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 }; // Determine if color is light (for text contrast on swatch) const isLightColor = (hex: string | null): boolean => { if (!hex) return false; const cleanHex = hex.replace('#', ''); const r = parseInt(cleanHex.slice(0, 2), 16); const g = parseInt(cleanHex.slice(2, 4), 16); const b = parseInt(cleanHex.slice(4, 6), 16); const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return luminance > 0.6; }; const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null; return (
{children} {/* Hover Card */} {isVisible && (
{/* 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}%` : '—'} {data.fillSource === 'spoolman' && data.fillLevel !== null && ( {t('spoolman.fillSourceLabel')} )}
{/* Fill bar */}
{data.fillLevel !== null ? (
) : (
)}
{/* Spoolman section - only show if enabled */} {spoolman?.enabled && data.trayUuid && (
{/* Tray UUID with copy button */}
{t('spoolman.spoolId')}
{/* Open in Spoolman button (when already linked) */} {spoolman.linkedSpoolId && spoolman.spoolmanUrl && ( e.stopPropagation()} className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green" title={t('spoolman.openInSpoolman')} > {t('spoolman.openInSpoolman')} )} {/* Link Spool button (when not linked) */} {!spoolman.linkedSpoolId && spoolman.onLinkSpool && ( )}
)} {/* Configure slot section - always show if enabled */} {configureSlot?.enabled && (
)}
{/* Arrow pointer */}
)}
); } interface EmptySlotHoverCardProps { children: ReactNode; className?: string; configureSlot?: ConfigureSlotConfig; } /** * Wrapper for empty slots - shows "Empty" on hover with optional configure button */ export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) { const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); 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); }; }, []); return (
{children} {isVisible && (
{t('ams.emptySlot')}
{/* Configure slot button */} {configureSlot?.enabled && (
)}
)}
); }