diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 24f45f2..56d614e 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -898,6 +898,62 @@ function closeViewDropdown() { if (trigger) trigger.setAttribute('aria-expanded', 'false'); } +/** + * Render the sidebar view dropdown menu (same data as the header dropdown, + * but no action buttons — navigation only). + */ +function renderSidebarViewDropdown() { + var menu = $('sidebar-view-dropdown-menu'); + if (!menu) return; + + var views = (_serverSettings && _serverSettings.views) || []; + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hiddenCount = hidden.length; + + var html = ''; + + // — All Sessions (always first) + var allActive = _activeView === 'all' ? ' view-dropdown-item--active' : ''; + html += ''; + + // — User views + if (views.length > 0) { + html += '
'; + for (var i = 0; i < views.length && i < 7; i++) { + var v = views[i]; + var vActive = _activeView === v.name ? ' view-dropdown-item--active' : ''; + html += ''; + } + } + + // — Hidden (N) (always last system view) + html += '
'; + var hiddenActive = _activeView === 'hidden' ? ' view-dropdown-item--active' : ''; + html += ''; + + menu.innerHTML = html; +} + +/** + * Toggle the sidebar view dropdown open/closed. + * Calls renderSidebarViewDropdown() when opening to ensure fresh content. + */ +function toggleSidebarViewDropdown() { + var menu = $('sidebar-view-dropdown-menu'); + var trigger = $('sidebar-view-dropdown-trigger'); + if (!menu) return; + + var isOpen = !menu.classList.contains('hidden'); + if (isOpen) { + menu.classList.add('hidden'); + if (trigger) trigger.setAttribute('aria-expanded', 'false'); + } else { + menu.classList.remove('hidden'); + if (trigger) trigger.setAttribute('aria-expanded', 'true'); + renderSidebarViewDropdown(); + } +} + /** * Show an inline text input inside the view dropdown for creating a new view. * Replaces the '+ New View' button with a text input inside the dropdown menu. @@ -1235,6 +1291,17 @@ function switchView(viewName) { renderGrid(_currentSessions || []); renderSidebar(_currentSessions || [], _viewingSession); renderViewDropdown(); + // Update sidebar view label to match the active view + var sidebarLabel = $('sidebar-view-label'); + if (sidebarLabel) { + if (viewName === 'all') { + sidebarLabel.textContent = 'All Sessions'; + } else if (viewName === 'hidden') { + sidebarLabel.textContent = 'Hidden'; + } else { + sidebarLabel.textContent = viewName; + } + } // Persist active view — fire and forget api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); } @@ -2777,6 +2844,23 @@ function bindStaticEventListeners() { }); } + // Sidebar view dropdown — trigger opens/closes, delegated item clicks switch view + var sidebarViewTrigger = $('sidebar-view-dropdown-trigger'); + if (sidebarViewTrigger) on(sidebarViewTrigger, 'click', toggleSidebarViewDropdown); + + var sidebarViewMenu = $('sidebar-view-dropdown-menu'); + if (sidebarViewMenu) { + sidebarViewMenu.addEventListener('click', function(e) { + var item = e.target.closest('[data-view]'); + if (item) { + switchView(item.dataset.view); + // Close sidebar dropdown after selection + sidebarViewMenu.classList.add('hidden'); + if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); + } + }); + } + // Click-outside closes the dropdown document.addEventListener('click', function(e) { var dropdown = $('view-dropdown-menu'); @@ -3204,6 +3288,9 @@ if (typeof module !== 'undefined' && module.exports) { closeViewDropdown, showNewViewInput, switchView, + // Sidebar view dropdown + renderSidebarViewDropdown, + toggleSidebarViewDropdown, // Manage Views settings tab renderViewsSettingsTab, _saveViewsAndRerender, diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index d4f3ad8..aa197c6 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -49,7 +49,13 @@
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 8475dbf..f3628fb 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -487,6 +487,41 @@ body { color: var(--text-muted); } +/* Sidebar view switcher dropdown */ +.sidebar-view-dropdown { + position: relative; + flex: 1; + min-width: 0; +} + +.sidebar-view-trigger { + display: flex; + align-items: center; + gap: 4px; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 13px; + font-family: var(--font-ui); + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.sidebar-view-trigger:hover { + color: var(--accent); +} + +#sidebar-view-dropdown-menu { + left: 0; + top: calc(100% + 2px); + min-width: 180px; +} + .sidebar-list { flex: 1; overflow-y: auto; diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 7b2ee85..72988ba 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -195,19 +195,16 @@ def test_html_reconnect_overlay_outside_view_body() -> None: def test_html_session_sidebar_structure() -> None: - """#session-sidebar must contain .sidebar-header (with .sidebar-title and #sidebar-collapse-btn) and #sidebar-list.""" + """#session-sidebar must contain .sidebar-header (with #sidebar-view-dropdown and #sidebar-collapse-btn) and #sidebar-list.""" soup = _SOUP sidebar = soup.find(id="session-sidebar") assert sidebar is not None, "Missing #session-sidebar" # .sidebar-header sidebar_header = sidebar.find(class_="sidebar-header") assert sidebar_header is not None, "Missing .sidebar-header inside #session-sidebar" - # .sidebar-title with text 'Sessions' - sidebar_title = sidebar_header.find(class_="sidebar-title") - assert sidebar_title is not None, "Missing .sidebar-title inside .sidebar-header" - assert "Sessions" in sidebar_title.get_text(), ( - f".sidebar-title text must contain 'Sessions', got: {sidebar_title.get_text()!r}" - ) + # #sidebar-view-dropdown (replaces old .sidebar-title) + sidebar_dropdown = sidebar_header.find(id="sidebar-view-dropdown") + assert sidebar_dropdown is not None, "Missing #sidebar-view-dropdown inside .sidebar-header" # #sidebar-collapse-btn collapse_btn = sidebar_header.find(id="sidebar-collapse-btn") assert collapse_btn is not None, ( @@ -1501,3 +1498,25 @@ def test_settings_has_views_panel() -> None: # Must contain the empty message element empty_msg = panel.find(id="views-settings-empty") assert empty_msg is not None, "Missing #views-settings-empty inside views panel" + + +# ============================================================ +# Sidebar View Switcher (task-10) +# ============================================================ + + +def test_sidebar_view_dropdown_exists() -> None: + """#sidebar-view-dropdown-trigger must exist in the sidebar header.""" + soup = _SOUP + trigger = soup.find(id="sidebar-view-dropdown-trigger") + assert trigger is not None, "Missing #sidebar-view-dropdown-trigger" + assert trigger.name == "button", ( + f"#sidebar-view-dropdown-trigger must be a