glam/frontend/src/components/layout/Navigation.tsx

626 lines
25 KiB
TypeScript

/**
* 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<string | null>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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<string, string> = {
'progressive': '🚀',
'geoapi': '🗄️',
'geoapi-lite': '⚡',
'ducklake': '🦆',
'auto': '🔄',
};
return icons[state.dataBackend] || '🔄';
};
const getBackendTooltip = () => {
const labels: Record<string, { nl: string; en: string }> = {
'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<string, string> = {
'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 (
<nav className={`navigation ${isCollapsed ? 'navigation--collapsed' : ''} ${transitionState === 'collapsing' ? 'navigation--collapsing' : ''} ${transitionState === 'expanding' ? 'navigation--expanding' : ''}`}>
{/* Small floating logo - always visible, becomes prominent when collapsed */}
{/* Clicking expands the header (primary way to expand on conversation page) */}
<button
className="nav-logo-small"
onClick={() => setCollapsedWithTransition(false)}
aria-label="Expand navigation header"
title="Klik om header te tonen"
type="button"
>
<img
src="/nde-icon-square.png"
alt="NDE"
className="nav-logo-small-img"
/>
<span className="nav-logo-small-tooltip">Klik om header te tonen</span>
</button>
<div className="nav-container">
<Link to="/" className="nav-brand">
{/* Big NDE Logo - fades out when collapsed */}
<img
src="/nde-icon-square.png"
alt="Netwerk Digitaal Erfgoed Logo"
className="nav-logo"
/>
<span className="nav-title">Bronhouder</span>
</Link>
{/* Mobile Hamburger Button */}
<button
className="nav-mobile-toggle"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileMenuOpen}
>
<span className={`hamburger ${mobileMenuOpen ? 'open' : ''}`}>
<span></span>
<span></span>
<span></span>
</span>
</button>
{/* Desktop Navigation Links with Dropdowns */}
<div className="nav-links" ref={dropdownRef}>
{/* Custodian Menu */}
<div className="nav-dropdown">
<button
className={`nav-dropdown-trigger ${isSubmenuActive(['/visualize', '/map', '/browse', '/stats']) ? 'active' : ''}`}
onClick={() => toggleDropdown('custodian')}
aria-expanded={openDropdown === 'custodian'}
aria-haspopup="true"
>
{t('custodian')}
<span className="nav-dropdown-chevron">{openDropdown === 'custodian' ? '▲' : '▼'}</span>
</button>
{openDropdown === 'custodian' && (
<div className="nav-dropdown-menu">
<Link to="/visualize" className={`nav-dropdown-item ${isActive('/visualize') ? 'active' : ''}`}>
{t('visualize')}
</Link>
<Link to="/map" className={`nav-dropdown-item ${isActive('/map') ? 'active' : ''}`}>
{t('map')}
</Link>
<Link to="/browse" className={`nav-dropdown-item ${isActive('/browse') ? 'active' : ''}`}>
{t('browse')}
</Link>
<Link to="/stats" className={`nav-dropdown-item ${isActive('/stats') ? 'active' : ''}`}>
{t('stats')}
</Link>
</div>
)}
</div>
{/* Ontology Menu */}
<div className="nav-dropdown">
<button
className={`nav-dropdown-trigger ${isSubmenuActive(['/linkml', '/datamap', '/ontology']) ? 'active' : ''}`}
onClick={() => toggleDropdown('ontology')}
aria-expanded={openDropdown === 'ontology'}
aria-haspopup="true"
>
{t('ontologyMain')}
<span className="nav-dropdown-chevron">{openDropdown === 'ontology' ? '▲' : '▼'}</span>
</button>
{openDropdown === 'ontology' && (
<div className="nav-dropdown-menu">
<Link to="/linkml" className={`nav-dropdown-item ${isActive('/linkml') ? 'active' : ''}`}>
{t('linkml')}
</Link>
<Link to="/datamap" className={`nav-dropdown-item ${isActive('/datamap') ? 'active' : ''}`}>
{t('datamap')}
</Link>
<Link to="/ontology" className={`nav-dropdown-item ${isActive('/ontology') ? 'active' : ''}`}>
{t('external')}
</Link>
</div>
)}
</div>
{/* Query Menu */}
<div className="nav-dropdown">
<button
className={`nav-dropdown-trigger ${isSubmenuActive(['/query-builder', '/conversation']) ? 'active' : ''}`}
onClick={() => toggleDropdown('query')}
aria-expanded={openDropdown === 'query'}
aria-haspopup="true"
>
{t('queryMain')}
<span className="nav-dropdown-chevron">{openDropdown === 'query' ? '▲' : '▼'}</span>
</button>
{openDropdown === 'query' && (
<div className="nav-dropdown-menu">
<Link to="/query-builder" className={`nav-dropdown-item ${isActive('/query-builder') ? 'active' : ''}`}>
{t('search')}
</Link>
<Link to="/conversation" className={`nav-dropdown-item ${isActive('/conversation') ? 'active' : ''}`}>
{t('conversation')}
</Link>
</div>
)}
</div>
{/* Settings Menu */}
<div className="nav-dropdown">
<button
className={`nav-dropdown-trigger ${isSubmenuActive(['/settings', '/roadmap', '/review']) ? 'active' : ''}`}
onClick={() => toggleDropdown('settings')}
aria-expanded={openDropdown === 'settings'}
aria-haspopup="true"
>
{t('settings')}
<span className="nav-dropdown-chevron">{openDropdown === 'settings' ? '▲' : '▼'}</span>
</button>
{openDropdown === 'settings' && (
<div className="nav-dropdown-menu">
<Link to="/roadmap" className={`nav-dropdown-item ${isActive('/roadmap') ? 'active' : ''}`}>
{t('roadmap')}
</Link>
<Link to="/settings" className={`nav-dropdown-item ${isActive('/settings') ? 'active' : ''}`}>
{t('preferences')}
</Link>
<a
href="https://bronhouder.nl/database"
className="nav-dropdown-item"
target="_blank"
rel="noopener noreferrer"
>
{t('database')}
<span className="nav-external-icon" aria-hidden="true"></span>
</a>
<Link to="/review" className={`nav-dropdown-item ${isActive('/review') ? 'active' : ''}`}>
{language === 'nl' ? 'Entity Review' : 'Entity Review'}
</Link>
</div>
)}
</div>
</div>
{/* User Account Dropdown - Desktop (includes settings toggles) */}
{user && (
<div className="nav-user nav-user--desktop" ref={userMenuRef}>
<button
className="nav-user-btn"
onClick={() => setUserMenuOpen(!userMenuOpen)}
aria-expanded={userMenuOpen}
aria-haspopup="true"
>
<span className="nav-user-icon">👤</span>
<span className="nav-user-name">{user.username}</span>
<span className="nav-user-chevron">{userMenuOpen ? '▲' : '▼'}</span>
</button>
{userMenuOpen && (
<div className="nav-user-menu">
<div className="nav-user-info">
<span className="nav-user-role">{user.role}</span>
</div>
{/* Settings Section in User Menu */}
<div className="nav-user-settings">
<div className="nav-user-settings-header">
{language === 'nl' ? 'Snel instellen' : 'Quick Settings'}
</div>
{/* Language Toggle */}
<button
className="nav-user-setting-item"
onClick={toggleLanguage}
aria-label={language === 'nl' ? 'Switch to English' : 'Schakel naar Nederlands'}
>
<span className="nav-user-setting-label">{language === 'nl' ? 'Taal' : 'Language'}</span>
<span className="nav-user-setting-value">
<span className={language === 'nl' ? 'lang-active' : 'lang-inactive'}>NL</span>
<span className="lang-separator">|</span>
<span className={language === 'en' ? 'lang-active' : 'lang-inactive'}>EN</span>
</span>
</button>
{/* Theme Toggle */}
<button
className="nav-user-setting-item"
onClick={cycleTheme}
title={getThemeTooltip()}
>
<span className="nav-user-setting-label">{language === 'nl' ? 'Thema' : 'Theme'}</span>
<span className="nav-user-setting-value">
<span className="theme-icon">{getThemeIcon()}</span>
{state.theme === 'system' && <span className="theme-auto-badge">A</span>}
</span>
</button>
{/* Backend Indicator */}
<button
className="nav-user-setting-item"
onClick={() => { setUserMenuOpen(false); navigate('/settings'); }}
title={getBackendTooltip()}
>
<span className="nav-user-setting-label">{language === 'nl' ? 'Backend' : 'Backend'}</span>
<span className="nav-user-setting-value">
<span className="backend-icon">{getBackendIcon()}</span>
<span className="backend-label">{getBackendLabel()}</span>
</span>
</button>
</div>
<button onClick={logout} className="nav-user-logout">
{t('signOut')}
</button>
</div>
)}
</div>
)}
{/* Mobile Menu Overlay */}
<div
className={`nav-mobile-menu ${mobileMenuOpen ? 'open' : ''}`}
ref={mobileMenuRef}
>
<div className="nav-mobile-links">
{/* Custodian Section */}
<div className="nav-mobile-section">
<div className="nav-mobile-section-title">{t('custodian')}</div>
<Link to="/visualize" className={`nav-mobile-link ${isActive('/visualize') ? 'active' : ''}`}>
{t('visualize')}
</Link>
<Link to="/map" className={`nav-mobile-link ${isActive('/map') ? 'active' : ''}`}>
{t('map')}
</Link>
<Link to="/browse" className={`nav-mobile-link ${isActive('/browse') ? 'active' : ''}`}>
{t('browse')}
</Link>
<Link to="/stats" className={`nav-mobile-link ${isActive('/stats') ? 'active' : ''}`}>
{t('stats')}
</Link>
</div>
{/* Ontology Section */}
<div className="nav-mobile-section">
<div className="nav-mobile-section-title">{t('ontologyMain')}</div>
<Link to="/linkml" className={`nav-mobile-link ${isActive('/linkml') ? 'active' : ''}`}>
{t('linkml')}
</Link>
<Link to="/datamap" className={`nav-mobile-link ${isActive('/datamap') ? 'active' : ''}`}>
{t('datamap')}
</Link>
<Link to="/ontology" className={`nav-mobile-link ${isActive('/ontology') ? 'active' : ''}`}>
{t('external')}
</Link>
</div>
{/* Query Section */}
<div className="nav-mobile-section">
<div className="nav-mobile-section-title">{t('queryMain')}</div>
<Link to="/query-builder" className={`nav-mobile-link ${isActive('/query-builder') ? 'active' : ''}`}>
{t('search')}
</Link>
<Link to="/conversation" className={`nav-mobile-link ${isActive('/conversation') ? 'active' : ''}`}>
{t('conversation')}
</Link>
</div>
{/* Settings Section */}
<div className="nav-mobile-section">
<div className="nav-mobile-section-title">{t('settings')}</div>
<Link to="/roadmap" className={`nav-mobile-link ${isActive('/roadmap') ? 'active' : ''}`}>
{t('roadmap')}
</Link>
<Link to="/settings" className={`nav-mobile-link ${isActive('/settings') ? 'active' : ''}`}>
{t('preferences')}
</Link>
<a
href="https://bronhouder.nl/database"
className="nav-mobile-link"
target="_blank"
rel="noopener noreferrer"
>
{t('database')}
<span className="nav-external-icon" aria-hidden="true"></span>
</a>
<Link to="/review" className={`nav-mobile-link ${isActive('/review') ? 'active' : ''}`}>
{language === 'nl' ? 'Entity Review' : 'Entity Review'}
</Link>
</div>
</div>
{/* Language Toggle & Theme Toggle & Backend - Mobile */}
<div className="nav-mobile-footer">
<div className="nav-mobile-toggles">
<button
className="nav-lang-toggle"
onClick={toggleLanguage}
aria-label={language === 'nl' ? 'Switch to English' : 'Schakel naar Nederlands'}
>
<span className={language === 'nl' ? 'lang-active' : 'lang-inactive'}>NL</span>
<span className="lang-separator">|</span>
<span className={language === 'en' ? 'lang-active' : 'lang-inactive'}>EN</span>
</button>
<button
className="nav-theme-toggle"
onClick={cycleTheme}
aria-label={getThemeTooltip()}
title={getThemeTooltip()}
>
<span className="theme-icon">{getThemeIcon()}</span>
{state.theme === 'system' && <span className="theme-auto-badge">A</span>}
</button>
<button
className="nav-backend-indicator"
onClick={() => { setMobileMenuOpen(false); navigate('/settings'); }}
aria-label={getBackendTooltip()}
title={getBackendTooltip()}
>
<span className="backend-icon">{getBackendIcon()}</span>
<span className="backend-label">{getBackendLabel()}</span>
</button>
</div>
{/* User Account - Mobile */}
{user && (
<div className="nav-mobile-user">
<span className="nav-user-icon">👤</span>
<span className="nav-user-name">{user.username}</span>
<span className="nav-user-role">({user.role})</span>
<button onClick={logout} className="nav-mobile-logout">
{t('signOut')}
</button>
</div>
)}
</div>
</div>
</div>
</nav>
);
}