import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { AppBar, Toolbar, Button, IconButton, Typography, Menu, MenuItem, Divider, Tooltip, Box, useMediaQuery, } from '@mui/material' import { Storage, MonitorHeart, Search, Info, Settings, Power, PowerOff, Language, Logout, Terminal, } from '@mui/icons-material' import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { ColorLens } from '@mui/icons-material' import { useI18nStore } from '../stores/i18n.store' import { useThemeStore } from '../stores/theme.store' import { useRedisStateStore } from '../stores/redis-state.store' import { useSettingsStore } from '../stores/settings.store' import { useCommonStore } from '../stores/common.store' import { useOverlayStore } from '../stores/overlay.store' import { useMainCommandStore } from '../stores/main-command.store' import { request, onSocketEvent } from '../stores/socket.service' import { useAuthStore } from '../stores/auth.store' import LoginPage from '../pages/login/LoginPage' import { trackPage } from '../stores/analytics' import { ALL_THEME_KEYS } from '../themes' import ConsoleDrawer from './ConsoleDrawer' const TOOLBAR_HEIGHT = 48 const LAYOUT_PADDING = 5 export default function Layout() { const navigate = useNavigate() const location = useLocation() // Stores const strings = useI18nStore(s => s.strings) const currentLang = useI18nStore(s => s.currentLang) const isLangAuto = useI18nStore(s => s.isAuto) const setLanguage = useI18nStore(s => s.setLanguage) const { themeKey, isAuto, setTheme } = useThemeStore() const connection = useRedisStateStore(s => s.connection) const connections = useRedisStateStore(s => s.connections) const version = useRedisStateStore(s => s.version) const hasRediSearch = useRedisStateStore(s => s.hasRediSearch) const consoleDrawerOpen = useRedisStateStore(s => s.consoleDrawerOpen) const toggleConsoleDrawer = useRedisStateStore(s => s.toggleConsoleDrawer) const settings = useSettingsStore() const { generalHandleError } = useCommonStore() const overlay = useOverlayStore() const { connect, disconnect } = useMainCommandStore() const { authChecked, authRequired, isAuthenticated, checkAuthStatus } = useAuthStore() const showLogin = authChecked && authRequired && !isAuthenticated useEffect(() => { checkAuthStatus().then(() => { const state = useAuthStore.getState() if (state.authRequired && !state.isAuthenticated) { overlay.hide() } }) }, [checkAuthStatus]) // Responsive breakpoints matching Angular layout const isWide = useMediaQuery('(min-width: 720px)') const isGtXs = useMediaQuery('(min-width: 600px)') const isGtSm = useMediaQuery('(min-width: 960px)') const isElectron = useMemo(() => /electron/i.test(navigator.userAgent), []) const connectionsList = connections?.list ?? [] // Connection name (computed, matches Angular) const connectionName = useMemo(() => { if (connection) { const fn = strings?.label?.connected return typeof fn === 'function' ? fn({ name: connection.name }) : connection.name } return strings?.intention?.connect }, [connection, strings]) // Track group mode reactively (Settings page toggles this in localStorage) const [groupMode, setGroupMode] = useState(() => { try { return localStorage.getItem('p3xr-connection-group-mode') === 'true' } catch { return false } }) useEffect(() => { const check = () => { try { setGroupMode(localStorage.getItem('p3xr-connection-group-mode') === 'true') } catch {} } window.addEventListener('storage', check) // Also poll since same-tab localStorage changes don't fire 'storage' const interval = setInterval(check, 1000) return () => { window.removeEventListener('storage', check); clearInterval(interval) } }, []) // Grouped connections const groupedConnectionsList = useMemo(() => { if (!groupMode) return [{ name: '', connections: connectionsList }] const groups = new Map() for (const conn of connectionsList) { const name = conn.group?.trim() || '' if (!groups.has(name)) groups.set(name, []) groups.get(name)!.push(conn) } return Array.from(groups, ([name, conns]) => ({ name, connections: conns })) }, [connectionsList, groupMode]) const isActivePage = (page: string) => { const url = location.pathname switch (page) { case 'database': return url.startsWith('/database') case 'search': return url === '/search' case 'monitoring': return url.startsWith('/monitoring') case 'info': return url === '/info' case 'settings': return url === '/settings' default: return false } } const navigateTo = (stateName: string) => { const routes: Record = { 'database.statistics': '/database/statistics', 'database': '/database', 'monitoring': '/monitoring', 'search': '/search', 'info': '/info', 'settings': '/settings', } navigate(routes[stateName] || `/${stateName}`) } const openLink = (target: string) => { const urls: Record = { github: 'https://github.com/patrikx3/redis-ui', githubRelease: 'https://github.com/patrikx3/redis-ui/releases', githubChangelog: 'https://github.com/patrikx3/redis-ui/blob/master/change-log.md#change-log', donate: 'https://www.paypal.me/patrikx3', } window.open(urls[target], '_blank') } // --- Menu anchors --- const [connectionAnchor, setConnectionAnchor] = useState(null) const [themeAnchor, setThemeAnchor] = useState(null) const [githubAnchor, setGithubAnchor] = useState(null) const [languageAnchor, setLanguageAnchor] = useState(null) // --- Language menu with search --- const [languageSearch, setLanguageSearch] = useState('') const [highlightedLangIdx, setHighlightedLangIdx] = useState(0) const languageInputRef = useRef(null) const availableLanguages = useMemo(() => Object.keys(strings?.language ?? {}), [strings]) const filteredLanguages = useMemo(() => { const search = languageSearch.trim().toLowerCase() if (!search) return availableLanguages return availableLanguages.filter(key => { const label = (strings?.language?.[key] ?? key).toLowerCase() return label.includes(search) || key.toLowerCase().includes(search) }) }, [availableLanguages, languageSearch, strings]) const languageLabel = useCallback((key: string): string => strings?.language?.[key] ?? key, [strings]) const onLanguageMenuOpen = () => { const idx = filteredLanguages.indexOf(currentLang) setHighlightedLangIdx(idx >= 0 ? idx : 0) // MUI Menu needs time to render before we can focus the input and scroll setTimeout(() => { languageInputRef.current?.focus() // Scroll current language into view const menu = document.querySelector('.p3xr-language-menu .MuiList-root') if (menu) { const items = menu.querySelectorAll('.MuiMenuItem-root') const target = items[idx >= 0 ? idx : 0] target?.scrollIntoView({ block: 'nearest' }) } }, 150) } const onLanguageMenuClose = () => { setLanguageSearch('') } const onLanguageKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setLanguageAnchor(null) return } if (e.key === 'Enter') { e.preventDefault() if (filteredLanguages.length > 0) { setLanguage(filteredLanguages[highlightedLangIdx]) setLanguageAnchor(null) } return } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() const len = filteredLanguages.length if (!len) return setHighlightedLangIdx(prev => e.key === 'ArrowDown' ? (prev + 1) % len : (prev - 1 + len) % len ) return } e.stopPropagation() } // Scroll highlighted language into view useEffect(() => { if (!languageAnchor) return setTimeout(() => { const menu = document.querySelector('.p3xr-language-menu .MuiList-root') if (!menu) return const items = menu.querySelectorAll('.MuiMenuItem-root') items[highlightedLangIdx]?.scrollIntoView({ block: 'nearest' }) }) }, [highlightedLangIdx, languageAnchor]) // Reflect drawer-open state on so CSS + JS recalc can size page content. // Only active when we're connected (no connection = no drawer = no space reserved). useEffect(() => { const active = consoleDrawerOpen && Boolean(connection) if (active) { document.documentElement.classList.add('p3xr-console-drawer-open') document.documentElement.style.setProperty('--p3xr-console-drawer-height-active', '30vh') } else { document.documentElement.classList.remove('p3xr-console-drawer-open') document.documentElement.style.setProperty('--p3xr-console-drawer-height-active', '0px') } }, [consoleDrawerOpen, connection]) // Body never scrolls — the fixed-height #p3xr-layout-content container // handles all page scrolling. Drawer and footer/header are position: fixed. useEffect(() => { const prev = document.body.style.overflow document.body.style.overflow = 'hidden' return () => { document.body.style.overflow = prev } }, []) // Ctrl+` (or Cmd+`) toggles the drawer globally useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === '`' && (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) { e.preventDefault() toggleConsoleDrawer() } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [toggleConsoleDrawer]) // --- Electron bridge --- useEffect(() => { if (!isElectron) return const handler = (event: MessageEvent) => { const data = event.data if (!data || typeof data.type !== 'string') return if (data.type === 'p3x-set-language' && typeof data.translation === 'string') { setLanguage(data.translation) } else if (data.type === 'p3x-menu' && typeof data.action === 'string') { navigateTo(data.action) } } window.addEventListener('message', handler) return () => { window.removeEventListener('message', handler) } }, [isElectron]) // Auto-connect from localStorage on startup (only when authenticated) useEffect(() => { if (!isAuthenticated) return try { const saved = localStorage.getItem(settings.connectInfoStorageKey) if (saved) { const conn = JSON.parse(saved) if (conn?.id) connect(conn) } } catch {} }, [isAuthenticated]) // Subscribe to redis disconnect → navigate to settings + reset connection state useEffect(() => { const unsub = onSocketEvent('redis-disconnected', () => { useRedisStateStore.setState({ connection: undefined, connectionState: 'none' }) navigateTo('settings') }) return unsub }, []) // Scroll-gutter tracker: aligns the console drawer's right edge with the // content's right edge. On monitoring pages the tab shell owns the scroll, // so only observe its inner content; everywhere else use layout-content. useEffect(() => { const updateScrollGutter = () => { const monitoring = document.querySelector('.p3xr-monitoring-shell-content') as HTMLElement | null const el = monitoring || document.getElementById('p3xr-layout-content') const gutter = el ? Math.max(0, el.offsetWidth - el.clientWidth) : 0 document.documentElement.style.setProperty('--p3xr-scroll-gutter', gutter + 'px') } let ro: ResizeObserver | null = null let mo: MutationObserver | null = null const bind = () => { ro?.disconnect() if (typeof ResizeObserver === 'undefined') return ro = new ResizeObserver(() => updateScrollGutter()) const monitoring = document.querySelector('.p3xr-monitoring-shell-content') as HTMLElement | null const el = monitoring || document.getElementById('p3xr-layout-content') if (el) { ro.observe(el); if (el.firstElementChild) ro.observe(el.firstElementChild) } } updateScrollGutter() bind() const root = document.getElementById('p3xr-layout-content') if (root && typeof MutationObserver !== 'undefined') { mo = new MutationObserver(() => { bind(); updateScrollGutter() }) mo.observe(root, { childList: true, subtree: true }) } window.addEventListener('resize', updateScrollGutter) return () => { ro?.disconnect() mo?.disconnect() window.removeEventListener('resize', updateScrollGutter) } }, []) // Prefetch other GUI frameworks — fetch HTML, parse script/style tags, cache all assets useEffect(() => { const timer = setTimeout(() => { for (const gui of ['/ng/', '/vue/']) { fetch(gui).then(r => r.text()).then(html => { const doc = new DOMParser().parseFromString(html, 'text/html') doc.querySelectorAll('script[src], link[rel="stylesheet"]').forEach(el => { const url = (el as any).src || (el as any).href if (url) fetch(url).catch(() => {}) }) }).catch(() => {}) } }, 3000) return () => clearTimeout(timer) }, []) // Promo toast — demo site only, once per session useEffect(() => { if (window.location.hostname !== 'p3x.redis.patrikx3.com') return if (sessionStorage.getItem('p3xr-promo-shown')) return const timer = setTimeout(() => { const promo = useI18nStore.getState().strings?.promo if (promo?.toastMessage) { sessionStorage.setItem('p3xr-promo-shown', '1') const msg = promo.toastMessage + (promo.disclaimer ? ' · ' + promo.disclaimer : '') useCommonStore.getState().toast(msg, 30000) } }, 5000) return () => clearTimeout(timer) }, []) // Track route changes for analytics (matches Angular setupRouteTracking) // Also updates the global currentPage signal — used by console drawer + AI context. useEffect(() => { const path = location.pathname.toLowerCase().startsWith('/database/key/') ? '/database/key' : location.pathname trackPage(path) const u = location.pathname.toLowerCase() const page = u.startsWith('/database') ? 'database' : u.startsWith('/monitoring/profiler') ? 'profiler' : u.startsWith('/monitoring/pubsub') ? 'pubsub' : u.startsWith('/monitoring/memory-analysis') || u.startsWith('/monitoring/analysis') ? 'analysis' : u.startsWith('/monitoring') ? 'pulse' : u.startsWith('/search') ? 'search' : u.startsWith('/timeseries') ? 'timeseries' : u.startsWith('/info') ? 'info' : u.startsWith('/settings') ? 'settings' : 'unknown' useRedisStateStore.setState({ currentPage: page as any }) }, [location.pathname]) // Show overlay on raw socket disconnect/error (matches Angular behavior, skip during login) useEffect(() => { const unsubDisconnect = onSocketEvent('disconnect', () => { if (showLogin) return overlay.show({ message: strings?.status?.socketDisconnected }) }) const unsubError = onSocketEvent('socket-error', () => { if (showLogin) return overlay.show({ message: strings?.status?.socketError }) }) return () => { unsubDisconnect(); unsubError() } }, [strings, showLogin]) // --- Responsive button helpers --- const isMatrixTheme = themeKey === 'matrix' const activeSx = { bgcolor: isMatrixTheme ? 'rgba(0,0,0,0.15)' : 'rgba(255,255,255,0.1)' } const NavBtn = ({ icon, label, tooltip, page, onClick }: { icon: React.ReactNode, label: string, tooltip?: string, page?: string, onClick: () => void }) => { const active = page ? isActivePage(page) : false return isWide ? ( ) : ( {icon} ) } const FooterBtn = ({ icon, label, onClick, bp = 'wide' }: { icon: React.ReactNode, label: string, onClick: (e: React.MouseEvent) => void, bp?: 'wide' | 'gtXs' | 'gtSm' }) => { const show = bp === 'gtXs' ? isGtXs : bp === 'gtSm' ? isGtSm : isWide return show ? ( ) : ( {icon} ) } return ( {/* ===== HEADER ===== */} } label={strings?.title?.name} tooltip={`${strings?.title?.name || ''}${version ? ' ' + version : ''}`} onClick={() => navigateTo(connection ? 'database.statistics' : 'settings')} /> {version && isWide && ( {version} )} {connection && ( } label={strings?.intention?.main} page="database" onClick={() => navigateTo('database.statistics')} /> )} {connection && ( } label={strings?.page?.monitor?.title} page="monitoring" onClick={() => navigateTo('monitoring')} /> )} {connection && hasRediSearch && ( } label={strings?.page?.search?.title} page="search" onClick={() => navigateTo('search')} /> )} {!showLogin && ( } label={strings?.intention?.info} page="info" onClick={() => navigateTo('info')} /> )} {!showLogin && ( } label={strings?.intention?.settings} page="settings" onClick={() => navigateTo('settings')} /> )} {/* Logout button — rightmost in header */} {authRequired && isAuthenticated && ( { try { await useCommonStore.getState().confirm({ message: strings?.intention?.logout, }) useAuthStore.getState().logout() } catch {} }}> )} {/* Version overlay — inside AppBar so it inherits toolbar text color */} {/* ===== CONTENT ===== */} {showLogin ? : } {/* ===== GLOBAL CONSOLE DRAWER (only when connected) ===== */} {!showLogin && connection && } {/* ===== FOOTER ===== */} {/* Connection menu — hidden during login */} {!showLogin && connectionsList.length > 0 && ( <> {isWide ? ( ) : ( setConnectionAnchor(e.currentTarget)}> )} setConnectionAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}> {groupedConnectionsList.map((group, gi) => [ groupedConnectionsList.length > 1 && ( {group.name || strings?.label?.ungrouped} ), ...group.connections.map((conn: any) => ( { setConnectionAnchor(null); connect(conn) }}> {conn.name} )), gi < groupedConnectionsList.length - 1 && groupedConnectionsList.length > 1 && ( ), ])} )} {/* Disconnect — hidden during login */} {!showLogin && connection && ( } label={strings?.intention?.disconnect} bp="gtSm" onClick={() => disconnect()} /> )} {/* Console drawer toggle — only when connected (no console without connection). */} {connection && (isWide ? ( ) : ( toggleConsoleDrawer()} aria-pressed={consoleDrawerOpen} sx={consoleDrawerOpen ? activeSx : undefined}> ))} {/* Language menu with search */} {isGtSm ? ( ) : ( { setLanguageAnchor(e.currentTarget); onLanguageMenuOpen() }}> )} { setLanguageAnchor(null); onLanguageMenuClose() }} className="p3xr-language-menu" disableAutoFocus disableEnforceFocus disableRestoreFocus autoFocus={false} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: { sx: { minWidth: 320, maxWidth: '90vw', maxHeight: 400, overflow: 'hidden' } }, list: { autoFocus: false, autoFocusItem: false, sx: { pt: 0, overflow: 'auto', maxHeight: 400 } }, }}> ({ position: 'sticky', top: 0, zIndex: 1, bgcolor: theme.palette.mode === 'dark' ? theme.palette.background.paper : 'background.paper', backgroundImage: theme.palette.mode === 'dark' ? 'linear-gradient(rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.12))' : 'none', px: 1, py: 1, overflow: 'hidden', })} onClick={e => e.stopPropagation()} onKeyDown={onLanguageKeyDown} > ) => { setLanguageSearch(e.target.value) setHighlightedLangIdx(0) }} autoComplete="off" sx={{ display: 'block', width: '100%', mx: 'auto', px: 1, py: 1, borderStyle: 'solid', borderWidth: 2, borderColor: 'rgba(255,255,255,0.25)', borderRadius: '4px', fontSize: 14, bgcolor: 'transparent', color: 'text.primary', outline: 'none', boxSizing: 'border-box', overflow: 'hidden', textOverflow: 'ellipsis', '&:focus': { borderWidth: 3, borderColor: 'primary.main', }, '&::placeholder': { color: 'text.secondary', opacity: 0.5, }, }} /> { setLanguage('auto'); setLanguageAnchor(null) }}> {strings?.label?.languageAuto} {filteredLanguages.map((key, i) => ( { setLanguage(key); setLanguageAnchor(null) }}> {languageLabel(key)} ))} {/* Theme menu — exact port of Angular theme menu */} {isGtXs ? ( ) : ( setThemeAnchor(e.currentTarget)}> )} setThemeAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}> { setTheme('auto'); setThemeAnchor(null) }}> {strings?.label?.themeAuto} {ALL_THEME_KEYS.map(key => ( { setTheme(key); setThemeAnchor(null) }}> {strings?.label?.theme?.[key] ?? key} ))} {/* GitHub menu */} {isGtSm ? ( ) : ( setGithubAnchor(e.currentTarget)}> )} setGithubAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}> { openLink('github'); setGithubAnchor(null) }}> {strings?.intention?.githubRepo} { openLink('githubRelease'); setGithubAnchor(null) }}> {strings?.intention?.githubRelease} { openLink('githubChangelog'); setGithubAnchor(null) }}> {strings?.intention?.githubChangelog} ) }