diff --git a/src/App.tsx b/src/App.tsx index d09f0a50..3a70f196 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import "./styles/rainbowkit.css"; import "./styles/responsive.css"; import "./styles/ai-analysis.css"; import "./styles/rpcs.css"; +import "./styles/helper-tooltip.css"; import Loading from "./components/common/Loading"; import { diff --git a/src/components/common/FieldLabel.tsx b/src/components/common/FieldLabel.tsx new file mode 100644 index 00000000..1f01b48d --- /dev/null +++ b/src/components/common/FieldLabel.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; +import type { KnowledgeLevel } from "../../types"; +import { useSettings } from "../../context/SettingsContext"; +import HelperTooltip from "./HelperTooltip"; + +interface FieldLabelProps { + label: string; + tooltipKey?: string; + visibleFor?: KnowledgeLevel[]; + className?: string; +} + +const FieldLabel: React.FC = ({ + label, + tooltipKey, + visibleFor, + className = "tx-label", +}) => { + const { settings } = useSettings(); + const { t } = useTranslation("tooltips"); + + const level = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const shouldShow = tooltipsEnabled && tooltipKey && (!visibleFor || visibleFor.includes(level)); + + return ( + + {label} + {shouldShow && } + + ); +}; + +export default FieldLabel; diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx new file mode 100644 index 00000000..58dc1f7e --- /dev/null +++ b/src/components/common/HelperTooltip.tsx @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface HelperTooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; + className?: string; +} + +const HOVER_DELAY_MS = 350; + +const HelperTooltip: React.FC = ({ content, placement = "top", className }) => { + const [isVisible, setIsVisible] = useState(false); + const [actualPlacement, setActualPlacement] = useState(placement); + const [triggerRect, setTriggerRect] = useState(null); + const tooltipId = useId(); + const triggerRef = useRef(null); + const bubbleRef = useRef(null); + const arrowRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + const isPointerInsideRef = useRef(false); + + const show = useCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + // Only auto-flip top↔bottom; left/right stay as requested + if (placement === "top" || placement === "bottom") { + setActualPlacement(rect.top < 80 ? "bottom" : placement); + } else { + setActualPlacement(placement); + } + setTriggerRect(rect); + } + setIsVisible(true); + }, [placement]); + + const hide = useCallback(() => { + setIsVisible(false); + }, []); + + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + const handlePointerEnter = useCallback(() => { + isPointerInsideRef.current = true; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(show, HOVER_DELAY_MS); + }, [show, clearHoverTimeout]); + + const handlePointerLeave = useCallback(() => { + isPointerInsideRef.current = false; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(() => { + if (!isPointerInsideRef.current) { + hide(); + } + }, 100); + }, [hide, clearHoverTimeout]); + + const handleFocus = useCallback(() => { + show(); + }, [show]); + + const handleBlur = useCallback(() => { + hide(); + }, [hide]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + hide(); + } + }, + [hide], + ); + + const handleClick = useCallback(() => { + if (isVisible) { + hide(); + } else { + show(); + } + }, [isVisible, show, hide]); + + // Close on outside click + useEffect(() => { + if (!isVisible) return; + + const handleOutsideClick = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if ( + triggerRef.current && + !triggerRef.current.contains(target) && + bubbleRef.current && + !bubbleRef.current.contains(target) + ) { + hide(); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("touchstart", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("touchstart", handleOutsideClick); + }; + }, [isVisible, hide]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + // Clamp bubble within viewport and position arrow after render + useLayoutEffect(() => { + if (!isVisible || !bubbleRef.current || !triggerRect) return; + const bubble = bubbleRef.current; + const arrow = arrowRef.current; + const rect = bubble.getBoundingClientRect(); + const margin = 8; + let needsClamp = false; + let left = rect.left; + let top = rect.top; + + // Horizontal clamping + if (rect.right > window.innerWidth - margin) { + left = window.innerWidth - margin - rect.width; + needsClamp = true; + } else if (rect.left < margin) { + left = margin; + needsClamp = true; + } + + // Vertical clamping + if (rect.bottom > window.innerHeight - margin) { + top = window.innerHeight - margin - rect.height; + needsClamp = true; + } else if (rect.top < margin) { + top = margin; + needsClamp = true; + } + + if (needsClamp) { + bubble.style.left = `${left}px`; + bubble.style.top = `${top}px`; + bubble.style.transform = "none"; + } + + // Position arrow to point at trigger center + if (arrow) { + const bubbleRect = bubble.getBoundingClientRect(); + const triggerCenterX = triggerRect.left + triggerRect.width / 2; + const triggerCenterY = triggerRect.top + triggerRect.height / 2; + const arrowSize = 5; + + if (actualPlacement === "top" || actualPlacement === "bottom") { + const arrowLeft = Math.max( + arrowSize, + Math.min(triggerCenterX - bubbleRect.left, bubbleRect.width - arrowSize), + ); + arrow.style.left = `${arrowLeft}px`; + } else { + const arrowTop = Math.max( + arrowSize, + Math.min(triggerCenterY - bubbleRect.top, bubbleRect.height - arrowSize), + ); + arrow.style.top = `${arrowTop}px`; + } + } + }, [isVisible, triggerRect, actualPlacement]); + + const getBubbleStyle = (): React.CSSProperties => { + if (!triggerRect) return {}; + const gap = 6; + const centerX = triggerRect.left + triggerRect.width / 2; + const centerY = triggerRect.top + triggerRect.height / 2; + + switch (actualPlacement) { + case "bottom": + return { + position: "fixed", + top: triggerRect.bottom + gap, + left: centerX, + transform: "translateX(-50%)", + }; + case "left": + return { + position: "fixed", + top: centerY, + left: triggerRect.left - gap, + transform: "translate(-100%, -50%)", + }; + case "right": + return { + position: "fixed", + top: centerY, + left: triggerRect.right + gap, + transform: "translateY(-50%)", + }; + default: + return { + position: "fixed", + top: triggerRect.top - gap, + left: centerX, + transform: "translate(-50%, -100%)", + }; + } + }; + + const bubble = isVisible ? ( + + ) : null; + + return ( + + + {bubble && createPortal(bubble, document.body)} + + ); +}; + +export default HelperTooltip; diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index aa6b0240..61c3d709 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { useSettings } from "../../context/SettingsContext"; +import { useNotify } from "../../hooks/useNotify"; import { useSearch } from "../../hooks/useSearch"; import NavbarLogo from "./NavbarLogo"; import { NetworkBlockIndicator } from "./NetworkBlockIndicator"; @@ -13,7 +14,9 @@ const Navbar = () => { const location = useLocation(); const { searchTerm, setSearchTerm, isResolving, error, clearError, handleSearch, networkId } = useSearch(); - const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode } = useSettings(); + const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode, settings, updateSettings } = + useSettings(); + const notify = useNotify(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Check if we should show the search box (on any network page including home) @@ -42,6 +45,30 @@ const Navbar = () => { }; }, [isMobileMenuOpen]); + const knowledgeLevel = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const cycleKnowledgeLevel = () => { + const levels = ["beginner", "intermediate", "advanced"] as const; + const currentIndex = levels.indexOf(knowledgeLevel); + const nextLevel = levels[(currentIndex + 1) % levels.length] ?? "beginner"; + updateSettings({ knowledgeLevel: nextLevel }); + const levelKey = + nextLevel === "beginner" + ? "nav.tooltipsLevelBeginner" + : nextLevel === "intermediate" + ? "nav.tooltipsLevelIntermediate" + : "nav.tooltipsLevelAdvanced"; + notify.success(t("nav.tooltipsSwitched", { level: t(levelKey) }), 2000); + }; + + const knowledgeLevelLabel = + knowledgeLevel === "beginner" + ? t("nav.tooltipsBeginner") + : knowledgeLevel === "intermediate" + ? t("nav.tooltipsIntermediate") + : t("nav.tooltipsAdvanced"); + const goToSettings = () => { navigate("/settings"); }; @@ -137,6 +164,40 @@ const Navbar = () => { )} + {tooltipsEnabled && ( +
  • + +
  • + )}
  • + {/* Tooltip Level toggle */} + {tooltipsEnabled && ( + + )} + {/* Super User Mode toggle */}