import React, { useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; import { Loader } from 'lucide-react'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; // --------------------------------------------------------------------------- // Severity color mapping — matches DESIGN_SYSTEM.md badge colors // --------------------------------------------------------------------------- const SEVERITY_COLORS = { Critical: { border: '#EF4444', bg: 'rgba(239, 68, 68, 0.25)', text: '#FCA5A5', dot: '#EF4444' }, High: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.25)', text: '#FCD34D', dot: '#F59E0B' }, Medium: { border: '#0EA5E9', bg: 'rgba(14, 165, 233, 0.25)', text: '#7DD3FC', dot: '#0EA5E9' }, Low: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.25)', text: '#6EE7B7', dot: '#10B981' }, }; // --------------------------------------------------------------------------- // Pure positioning function — exported for testability // --------------------------------------------------------------------------- const TOOLTIP_GAP = 8; const ARROW_SIZE = 6; export function calcTooltipPosition(anchorRect, tooltipHeight, viewportHeight) { const spaceAbove = anchorRect.top; const spaceBelow = viewportHeight - anchorRect.bottom; const needed = tooltipHeight + TOOLTIP_GAP + ARROW_SIZE; const placeAbove = spaceAbove >= needed || spaceAbove >= spaceBelow; let top; if (placeAbove) { top = anchorRect.top - tooltipHeight - TOOLTIP_GAP - ARROW_SIZE; if (top < 0) top = 0; } else { top = anchorRect.bottom + TOOLTIP_GAP + ARROW_SIZE; if (top + tooltipHeight > viewportHeight) top = viewportHeight - tooltipHeight; } const left = anchorRect.left + anchorRect.width / 2; return { top, left, placeAbove }; } // --------------------------------------------------------------------------- // CveTooltip component // --------------------------------------------------------------------------- export default function CveTooltip({ cveId, anchorRect, cache }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { if (!cveId) { setData(null); setLoading(false); return; } // Check cache first if (cache.current.has(cveId)) { setData(cache.current.get(cveId)); setLoading(false); return; } // Cache miss — fetch from API const controller = new AbortController(); setLoading(true); setData(null); fetch(`${API_BASE}/cves/${encodeURIComponent(cveId)}/tooltip`, { credentials: 'include', signal: controller.signal, }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((payload) => { cache.current.set(cveId, payload); setData(payload); setLoading(false); }) .catch((err) => { if (err.name === 'AbortError') return; // Do not cache transient errors console.error('CveTooltip fetch error:', err); setData(null); setLoading(false); }); return () => controller.abort(); }, [cveId, cache]); // Nothing to show if (!cveId || !anchorRect) return null; if (!loading && !data) return null; if (data && data.exists === false) return null; const severity = data?.severity || ''; const colors = SEVERITY_COLORS[severity] || SEVERITY_COLORS.Medium; return ReactDOM.createPortal( , document.body, ); } // --------------------------------------------------------------------------- // TooltipBody — inner component that measures itself for positioning // --------------------------------------------------------------------------- function TooltipBody({ data, loading, anchorRect, colors, severity }) { const tooltipRef = React.useRef(null); const [pos, setPos] = React.useState({ top: 0, left: 0, placeAbove: true }); React.useLayoutEffect(() => { if (!tooltipRef.current || !anchorRect) return; const rect = tooltipRef.current.getBoundingClientRect(); const vp = window.innerHeight; setPos(calcTooltipPosition(anchorRect, rect.height, vp)); }, [anchorRect, data, loading]); const tooltipStyle = { position: 'fixed', zIndex: 99999, top: pos.top, left: pos.left, transform: 'translateX(-50%)', maxWidth: 320, minWidth: 200, background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.98), rgba(51, 65, 85, 0.95))', border: `1.5px solid ${colors.border}`, borderRadius: '0.5rem', padding: '0.75rem', boxShadow: `0 8px 24px rgba(0, 0, 0, 0.6), 0 0 16px ${colors.border}33`, pointerEvents: 'none', transition: 'opacity 0.15s ease', }; // Directional arrow const arrowStyle = { position: 'absolute', left: '50%', transform: 'translateX(-50%)', width: 0, height: 0, borderLeft: `${ARROW_SIZE}px solid transparent`, borderRight: `${ARROW_SIZE}px solid transparent`, ...(pos.placeAbove ? { bottom: -ARROW_SIZE, borderTop: `${ARROW_SIZE}px solid ${colors.border}`, borderBottom: 'none', } : { top: -ARROW_SIZE, borderBottom: `${ARROW_SIZE}px solid ${colors.border}`, borderTop: 'none', }), }; return (
{/* Arrow */}
{loading ? (
) : data && data.exists ? ( <> {/* CVE ID header */}
{data.cve_id}
{/* Severity badge */} {severity && (
{/* Glow dot */} {severity}
)} {/* Description */} {data.description && (
{data.description}
)} ) : null}
); }