/** * Navigation Component * Styled following Netwerk Digitaal Erfgoed (NDE) house style * With bilingual support (NL/EN) */ import { useState, useRef, useEffect, useCallback } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { useLanguage, translations } from '../../contexts/LanguageContext'; import { useUIState } from '../../contexts/UIStateContext'; import { isTargetInsideAny } from '../../utils/dom'; import './Navigation.css'; export function Navigation() { const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); const { language, toggleLanguage } = useLanguage(); const { state, setTheme } = useUIState(); const [userMenuOpen, setUserMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); const [transitionState, setTransitionState] = useState<'idle' | 'collapsing' | 'expanding'>('idle'); const [openDropdown, setOpenDropdown] = useState(null); const userMenuRef = useRef(null); const mobileMenuRef = useRef(null); const dropdownRef = useRef(null); // Helper to set collapsed state with transition animation const setCollapsedWithTransition = useCallback((newCollapsed: boolean) => { setIsCollapsed(prevCollapsed => { if (prevCollapsed === newCollapsed) return prevCollapsed; // Trigger transition animation setTransitionState(newCollapsed ? 'collapsing' : 'expanding'); // Clear transition state after animation completes setTimeout(() => { setTransitionState('idle'); }, 350); return newCollapsed; }); }, []); // Check if we're on a page where scroll-up should NOT auto-expand header // User must click the small logo to expand on these pages const isNoAutoExpandPage = location.pathname.startsWith('/conversation') || location.pathname.startsWith('/linkml'); // Detect scrolling from ANY scrollable element on the page // Also detect wheel events on non-scrollable areas useEffect(() => { let wheelDeltaAccumulator = 0; let lastWheelTime = 0; // Thresholds const COLLAPSE_WHEEL_THRESHOLD = 40; // Wheel delta to collapse (easy) const SCROLL_COLLAPSE_THRESHOLD = 20; // Scroll position to collapse const WHEEL_RESET_DELAY = 300; // Reset wheel accumulator after this many ms of no wheel events // Internal scrollable containers that should NOT trigger header collapse/expand const IGNORED_SCROLL_CONTAINERS = [ '.navigation', '.nav-mobile-menu', '.conversation-chat__messages', '.conversation-chat__history-list', '.conversation-viz__content', '.conversation-viz__table-container' ]; const handleScroll = (e: Event) => { // Completely ignore scroll events from internal scrollable containers if (isTargetInsideAny(e.target, IGNORED_SCROLL_CONTAINERS)) { return; } // Get scroll position from the target element const target = e.target as Element; const scrollTop = target instanceof HTMLElement ? target.scrollTop : 0; // Collapse header if scrolled past threshold (works everywhere) if (scrollTop > SCROLL_COLLAPSE_THRESHOLD) { setCollapsedWithTransition(true); } else if (scrollTop <= 5) { // On no-auto-expand pages: NEVER expand via scroll (user must click small logo) // On other pages: expand when at the very top if (!isNoAutoExpandPage) { setCollapsedWithTransition(false); wheelDeltaAccumulator = 0; } } }; // Wheel event handler for non-scrollable areas const handleWheel = (e: WheelEvent) => { // Ignore wheel events from internal scrollable containers if (isTargetInsideAny(e.target, IGNORED_SCROLL_CONTAINERS)) { return; } const now = Date.now(); // Reset accumulator if enough time has passed (prevents stale accumulation) if (now - lastWheelTime > WHEEL_RESET_DELAY) { wheelDeltaAccumulator = 0; } lastWheelTime = now; // Accumulate wheel delta wheelDeltaAccumulator += e.deltaY; // Collapse on scroll down intent (easy threshold) if (wheelDeltaAccumulator > COLLAPSE_WHEEL_THRESHOLD) { setCollapsedWithTransition(true); } // Note: We never expand on wheel scroll-up. Use small logo click instead. }; // Use capture phase to catch scroll events from any element document.addEventListener('scroll', handleScroll, { capture: true, passive: true }); // Wheel events for non-scrollable areas document.addEventListener('wheel', handleWheel, { passive: true }); return () => { document.removeEventListener('scroll', handleScroll, { capture: true }); document.removeEventListener('wheel', handleWheel); }; }, [setCollapsedWithTransition, isNoAutoExpandPage]); // Determine effective theme (for icon display) const getEffectiveTheme = () => { if (state.theme === 'system') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } return state.theme; }; const [effectiveTheme, setEffectiveTheme] = useState(getEffectiveTheme()); // Update effective theme when state.theme changes or system preference changes useEffect(() => { setEffectiveTheme(getEffectiveTheme()); if (state.theme === 'system') { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = () => setEffectiveTheme(getEffectiveTheme()); mediaQuery.addEventListener('change', handleChange); return () => mediaQuery.removeEventListener('change', handleChange); } }, [state.theme]); // Cycle through themes: light -> dark -> system -> light const cycleTheme = () => { const themeOrder: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; const currentIndex = themeOrder.indexOf(state.theme); const nextIndex = (currentIndex + 1) % themeOrder.length; setTheme(themeOrder[nextIndex]); }; // Get theme icon and tooltip const getThemeIcon = () => { if (state.theme === 'system') { return effectiveTheme === 'dark' ? '🌙' : '☀️'; } return state.theme === 'dark' ? '🌙' : '☀️'; }; const getThemeTooltip = () => { const labels = { light: language === 'nl' ? 'Licht thema (klik voor donker)' : 'Light theme (click for dark)', dark: language === 'nl' ? 'Donker thema (klik voor systeem)' : 'Dark theme (click for system)', system: language === 'nl' ? 'Systeemthema (klik voor licht)' : 'System theme (click for light)', }; return labels[state.theme]; }; // Get data backend icon and tooltip const getBackendIcon = () => { const icons: Record = { 'progressive': '🚀', 'geoapi': '🗄️', 'geoapi-lite': '⚡', 'ducklake': '🦆', 'auto': '🔄', }; return icons[state.dataBackend] || '🔄'; }; const getBackendTooltip = () => { const labels: Record = { 'progressive': { nl: 'Progressive laden (cache-first)', en: 'Progressive loading (cache-first)' }, 'geoapi': { nl: 'GeoAPI (volledige data)', en: 'GeoAPI (full data)' }, 'geoapi-lite': { nl: 'GeoAPI Lite (snel)', en: 'GeoAPI Lite (fast)' }, 'ducklake': { nl: 'DuckLake (client-side)', en: 'DuckLake (client-side)' }, 'auto': { nl: 'Automatisch (beste beschikbaar)', en: 'Auto (best available)' }, }; const label = labels[state.dataBackend] || labels['auto']; const clickHint = language === 'nl' ? 'Klik om te wijzigen' : 'Click to change'; return `${label[language]} - ${clickHint}`; }; const getBackendLabel = () => { const labels: Record = { 'progressive': 'PRG', 'geoapi': 'API', 'geoapi-lite': 'LTE', 'ducklake': 'DLK', 'auto': 'AUT', }; return labels[state.dataBackend] || 'AUT'; }; // Close menus when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) { setUserMenuOpen(false); } if (mobileMenuRef.current && !mobileMenuRef.current.contains(event.target as Node)) { setMobileMenuOpen(false); } if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setOpenDropdown(null); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Close mobile menu on route change and reset collapsed state useEffect(() => { setMobileMenuOpen(false); setIsCollapsed(false); setOpenDropdown(null); }, [location.pathname]); const isActive = (path: string) => { return location.pathname === path; }; // Check if any path in the submenu is active const isSubmenuActive = (paths: string[]) => { return paths.some(path => location.pathname === path); }; // Toggle dropdown menu const toggleDropdown = (menu: string) => { setOpenDropdown(openDropdown === menu ? null : menu); }; // Get translated nav text const t = (key: keyof typeof translations.nav) => { return language === 'en' ? translations.nav[key].en : translations.nav[key].nl; }; return ( ); }