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, } 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 { ALL_THEME_KEYS } from '../themes' 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 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 settings = useSettingsStore() const { generalHandleError } = useCommonStore() const overlay = useOverlayStore() const { disconnect } = useMainCommandStore() // Connect to a Redis connection (exact port of Angular LayoutComponent.connect) const connect = async (conn: any) => { const cloned = structuredClone(conn) try { const dbStorageKey = settings.getStorageKeyCurrentDatabase(cloned.id) let db: string | undefined try { db = localStorage.getItem(dbStorageKey) ?? undefined } catch {} overlay.show({ message: strings?.title?.connectingRedis }) const response = await request({ action: 'connection-connect', payload: { connection: cloned, db }, }) // Update state const databaseIndexes: number[] = [] let i = 0 while (i < response.databases) databaseIndexes.push(i++) const commands: string[] = [] Object.keys(response.commands ?? {}).forEach(k => { commands.push(response.commands[k][0]) }) commands.sort() const modules = Array.isArray(response.modules) ? response.modules : [] useRedisStateStore.setState({ page: 1, monitor: false, dbsize: response.dbsize, databaseIndexes, connection: cloned, commands, commandsMeta: response.commandsMeta ?? {}, modules, hasReJSON: modules.some((m: any) => m.name === 'ReJSON'), hasRediSearch: modules.some((m: any) => m.name === 'search'), hasTimeSeries: modules.some((m: any) => m.name === 'timeseries' || m.name === 'Timeseries'), }) useCommonStore.getState().loadRedisInfoResponse({ response }) // Save last connection to localStorage try { localStorage.setItem(settings.connectInfoStorageKey, JSON.stringify(cloned)) } catch {} } catch (error) { try { localStorage.removeItem(settings.connectInfoStorageKey) } catch {} useRedisStateStore.setState({ connection: undefined }) generalHandleError(error) } finally { overlay.hide() } } // 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) // Close language menu on resize to avoid stale positioning useEffect(() => { const onResize = () => { if (languageAnchor) setLanguageAnchor(null) } window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [languageAnchor]) 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]) // --- 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) } } const timer = setTimeout(() => window.addEventListener('message', handler), 3000) return () => { clearTimeout(timer); window.removeEventListener('message', handler) } }, [isElectron]) // Remove loading splash useEffect(() => { document.getElementById('p3xr-loading')?.remove() }, []) // Auto-connect from localStorage on startup useEffect(() => { try { const saved = localStorage.getItem(settings.connectInfoStorageKey) if (saved) { const conn = JSON.parse(saved) if (conn?.id) connect(conn) } } catch {} }, []) // Subscribe to redis disconnect → navigate to settings useEffect(() => { const unsub = onSocketEvent('redis-disconnected', () => { navigateTo('settings') }) return unsub }, []) // --- Responsive button helpers --- const activeSx = { bgcolor: 'rgba(255,255,255,0.1)' } const NavBtn = ({ icon, label, page, onClick }: { icon: React.ReactNode, label: 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} onClick={() => navigateTo('database.statistics')} /> {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')} /> )} } label={strings?.intention?.info} page="info" onClick={() => navigateTo('info')} /> } label={strings?.intention?.settings} page="settings" onClick={() => navigateTo('settings')} /> {/* Version overlay */} {!isElectron && version && isWide && ( {version} )} {/* ===== CONTENT ===== */} {/* ===== FOOTER ===== */} {/* Connection menu */} {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 */} {connection && ( } label={strings?.intention?.disconnect} bp="gtSm" onClick={() => disconnect()} /> )} {/* Donate */} } label={strings?.title?.donate} onClick={() => openLink('donate')} /> {/* Language menu with search */} {isGtSm ? ( ) : ( { setLanguageAnchor(e.currentTarget); onLanguageMenuOpen() }}> )} { setLanguageAnchor(null); onLanguageMenuClose() }} className="p3xr-language-menu" anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} slotProps={{ paper: { sx: { minWidth: 320, maxWidth: '90vw', maxHeight: 400, overflow: 'hidden' } }, list: { sx: { pt: 0, overflow: 'auto', maxHeight: 400 } }, }}> 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, }, }} /> {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} ) }