diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index d2f99f2..1f83359 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -142,7 +142,6 @@ let _pollingTimer; let _heartbeatTimer; let _notificationPermission = 'default'; let _pollFailCount = 0; -let _previewPopover = null; let _previewTimer = null; var _previewSessionName = null; // track by NAME, not DOM element @@ -682,6 +681,9 @@ function filterVisible(sessions, settings, view, options) { return live.filter(function (s) { return inView(s) && !isSessionHidden(s); }); } +// Expose for component +window.filterVisible = filterVisible; + function visibleCount(sessions, settings, view, options) { return filterVisible(sessions, settings, view, options).length; } @@ -1036,167 +1038,56 @@ function bindSidebarClickAway() { // --------------------------------------------------------------------------- /** - * Populate #view-dropdown-menu with the full view list and update the label. + * Update component properties for the header dropdown. * Called on open and after a view switch. */ function renderViewDropdown() { - var menu = $('view-dropdown-menu'); - if (!menu) return; - - var views = (_serverSettings && _serverSettings.views) || []; - var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden"); - - var html = ''; - - // — All Sessions (always first) — show count of non-hidden sessions - var allCount = visibleCount(_currentSessions, _serverSettings, "all"); - 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 += ''; - - // — Actions (stronger separator) - html += '
'; - // Only show "Manage [ViewName]\u2026" when a user view is active - if (_activeView !== 'all' && _activeView !== 'hidden') { - var displayViewName = _activeView.length > 20 ? _activeView.substring(0, 20) + '\u2026' : _activeView; - html += ''; - } - html += ''; - html += ''; - - menu.innerHTML = html; - - // Update the label - var label = $('view-dropdown-label'); - if (label) { - if (_activeView === 'all') { - label.textContent = 'All Sessions'; - } else if (_activeView === 'hidden') { - label.textContent = 'Hidden'; - } else { - label.textContent = _activeView; - } - } + var dropdown = $('view-dropdown'); + if (!dropdown) return; + dropdown.views = (_serverSettings && _serverSettings.views) || []; + dropdown.activeView = _activeView; + dropdown.sessions = _currentSessions || []; + dropdown.settings = _serverSettings; } /** - * Toggle the view dropdown open/closed. - * Calls renderViewDropdown() when opening to ensure fresh content. + * Toggle the header view dropdown open/closed. */ function toggleViewDropdown() { - var menu = $('view-dropdown-menu'); - var trigger = $('view-dropdown-trigger'); - if (!menu) return; - - var isOpen = !menu.classList.contains('hidden'); - if (isOpen) { - closeViewDropdown(); - } else { - menu.classList.remove('hidden'); - if (trigger) trigger.setAttribute('aria-expanded', 'true'); - renderViewDropdown(); - } + var dropdown = $('view-dropdown'); + if (!dropdown) return; + dropdown.open = !dropdown.open; + if (dropdown.open) renderViewDropdown(); } /** - * Close the view dropdown. Removes inline new-view input if present. + * Close the header view dropdown. */ function closeViewDropdown() { - var menu = $('view-dropdown-menu'); - var trigger = $('view-dropdown-trigger'); - if (menu) { - menu.classList.add('hidden'); - // Remove any inline new-view input - var newViewInput = menu.querySelector('.view-dropdown__new-input'); - if (newViewInput) newViewInput.remove(); - } - if (trigger) trigger.setAttribute('aria-expanded', 'false'); + var dropdown = $('view-dropdown'); + if (dropdown) dropdown.open = false; } /** - * Render the sidebar view dropdown menu (same data as the header dropdown, - * but no action buttons — navigation only). + * Update component properties for the sidebar dropdown. */ function renderSidebarViewDropdown() { - var menu = $('sidebar-view-dropdown-menu'); - if (!menu) return; - - var views = (_serverSettings && _serverSettings.views) || []; - var hiddenCount = visibleCount(_currentSessions, _serverSettings, "hidden"); - - var html = ''; - - // — All Sessions (always first) — show count of non-hidden sessions - var sbAllCount = visibleCount(_currentSessions, _serverSettings, "all"); - 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 += ''; - - // — Actions (stronger separator) - html += '
'; - // Only show "Manage [ViewName]…" when a user view is active - if (_activeView !== 'all' && _activeView !== 'hidden') { - var sbDisplayViewName = _activeView.length > 20 ? _activeView.substring(0, 20) + '…' : _activeView; - html += ''; - } - html += ''; - html += ''; - - menu.innerHTML = html; + var dropdown = $('sidebar-view-dropdown'); + if (!dropdown) return; + dropdown.views = (_serverSettings && _serverSettings.views) || []; + dropdown.activeView = _activeView; + dropdown.sessions = _currentSessions || []; + dropdown.settings = _serverSettings; } /** * 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 { - // Position with fixed coordinates to escape sidebar overflow:hidden clipping - if (trigger) { - var rect = trigger.getBoundingClientRect(); - menu.style.top = (rect.bottom + 2) + 'px'; - menu.style.left = rect.left + 'px'; - } - menu.classList.remove('hidden'); - if (trigger) trigger.setAttribute('aria-expanded', 'true'); - renderSidebarViewDropdown(); - } + var dropdown = $('sidebar-view-dropdown'); + if (!dropdown) return; + dropdown.open = !dropdown.open; + if (dropdown.open) renderSidebarViewDropdown(); } /** @@ -1210,7 +1101,8 @@ function toggleSidebarViewDropdown() { * - On blur: closes the dropdown after 150ms if input is no longer focused. */ function showNewViewInput() { - var menu = $('view-dropdown-menu'); + var dropdown = $('view-dropdown'); + var menu = dropdown && dropdown.querySelector('.view-dropdown__menu'); if (!menu) return; // Re-focus existing input instead of creating a duplicate @@ -1288,7 +1180,8 @@ function showNewViewInput() { * - On Escape / blur: closes the sidebar dropdown. */ function showSidebarNewViewInput() { - var menu = $('sidebar-view-dropdown-menu'); + var dropdown = $('sidebar-view-dropdown'); + var menu = dropdown && dropdown.querySelector('.view-dropdown__menu'); if (!menu) return; // Re-focus existing input instead of creating a duplicate @@ -1619,17 +1512,7 @@ function switchView(viewName) { renderGrid(_currentSessions || []); renderSidebar(_currentSessions || [], _viewingSession, _viewingRemoteId); 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; - } - } + renderSidebarViewDropdown(); // Persist active view — fire and forget api('PATCH', '/api/state', { active_view: viewName }).catch(function() {}); } @@ -3995,97 +3878,38 @@ function bindStaticEventListeners() { on($('back-btn'), 'click', closeSession); - // View dropdown — trigger opens/closes, delegated item clicks switch view - var viewDropdownTrigger = $('view-dropdown-trigger'); - if (viewDropdownTrigger) on(viewDropdownTrigger, 'click', toggleViewDropdown); - - var viewDropdownMenu = $('view-dropdown-menu'); - if (viewDropdownMenu) { - viewDropdownMenu.addEventListener('click', function(e) { - var item = e.target.closest('[data-view]'); - if (item) { - switchView(item.dataset.view); - return; - } - var action = e.target.closest('[data-action]'); - if (action) { - if (action.dataset.action === 'new-view') { - showNewViewInput(); - } else if (action.dataset.action === 'manage-view') { - closeViewDropdown(); - openManageViewPanel(); - } else if (action.dataset.action === 'manage-views') { - closeViewDropdown(); - openSettings(); - switchSettingsTab('views'); - } else { - closeViewDropdown(); - } - } - }); + // View dropdown — component event listeners + var headerDropdown = $('view-dropdown'); + if (headerDropdown) { + headerDropdown.addEventListener('dropdown-toggle', function() { toggleViewDropdown(); }); + headerDropdown.addEventListener('dropdown-close', function() { closeViewDropdown(); }); + headerDropdown.addEventListener('view-switch', function(e) { switchView(e.detail.view); }); + headerDropdown.addEventListener('view-new-input', function() { showNewViewInput(); }); + headerDropdown.addEventListener('view-manage', function() { closeViewDropdown(); openManageViewPanel(); }); + headerDropdown.addEventListener('view-manage-all', function() { closeViewDropdown(); openSettings(); switchSettingsTab('views'); }); } - // 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'); - return; - } - var action = e.target.closest('[data-action]'); - if (action) { - if (action.dataset.action === 'new-view') { - showSidebarNewViewInput(); - } else if (action.dataset.action === 'manage-view') { - sidebarViewMenu.classList.add('hidden'); - if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); - openManageViewPanel(); - } else if (action.dataset.action === 'manage-views') { - sidebarViewMenu.classList.add('hidden'); - if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); - openSettings(); - switchSettingsTab('views'); - } else { - sidebarViewMenu.classList.add('hidden'); - if (sidebarViewTrigger) sidebarViewTrigger.setAttribute('aria-expanded', 'false'); - } - } + // Sidebar view dropdown — component event listeners + var sidebarDropdown = $('sidebar-view-dropdown'); + if (sidebarDropdown) { + sidebarDropdown.addEventListener('dropdown-toggle', function() { toggleSidebarViewDropdown(); }); + sidebarDropdown.addEventListener('dropdown-close', function() { if (sidebarDropdown) sidebarDropdown.open = false; }); + sidebarDropdown.addEventListener('view-switch', function(e) { + switchView(e.detail.view); + if (sidebarDropdown) sidebarDropdown.open = false; + }); + sidebarDropdown.addEventListener('view-new-input', function() { showSidebarNewViewInput(); }); + sidebarDropdown.addEventListener('view-manage', function() { + if (sidebarDropdown) sidebarDropdown.open = false; + openManageViewPanel(); + }); + sidebarDropdown.addEventListener('view-manage-all', function() { + if (sidebarDropdown) sidebarDropdown.open = false; + openSettings(); + switchSettingsTab('views'); }); } - // Click-outside closes the header view dropdown - document.addEventListener('click', function(e) { - var dropdown = $('view-dropdown-menu'); - if (!dropdown || dropdown.classList.contains('hidden')) return; - var trigger = $('view-dropdown-trigger'); - if (trigger && trigger.contains(e.target)) return; - // Don't close if a new-view input was just created (replaceChild removes the click target from DOM) - if (dropdown.querySelector('.view-dropdown__new-input')) return; - if (!dropdown.contains(e.target)) closeViewDropdown(); - }); - - // Click-outside closes the sidebar view dropdown - document.addEventListener('click', function(e) { - var sidebarDropdown = $('sidebar-view-dropdown-menu'); - if (!sidebarDropdown || sidebarDropdown.classList.contains('hidden')) return; - var sidebarTrigger = $('sidebar-view-dropdown-trigger'); - if (sidebarTrigger && sidebarTrigger.contains(e.target)) return; - // Don't close if a new-view input was just created (replaceChild removes the click target from DOM) - if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return; - if (!sidebarDropdown.contains(e.target)) { - sidebarDropdown.classList.add('hidden'); - if (sidebarTrigger) sidebarTrigger.setAttribute('aria-expanded', 'false'); - } - }); - var newSessionBtn = $('new-session-btn'); if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); var sidebarNewSessionBtn = $('sidebar-new-session-btn'); @@ -4425,13 +4249,7 @@ document.addEventListener('DOMContentLoaded', async function() { startHeartbeat(); bindStaticEventListeners(); renderViewDropdown(); - // Update sidebar label after restoreState sets _activeView (Issue 7) - var sidebarLabelEl = $('sidebar-view-label'); - if (sidebarLabelEl) { - if (_activeView === 'all') sidebarLabelEl.textContent = 'All Sessions'; - else if (_activeView === 'hidden') sidebarLabelEl.textContent = 'Hidden'; - else sidebarLabelEl.textContent = _activeView; - } + renderSidebarViewDropdown(); }) .catch(function(err) { console.error('[init] restoreState failed, retrying in 5s:', err); diff --git a/muxplex/frontend/components/store.js b/muxplex/frontend/components/store.js new file mode 100644 index 0000000..67e0b47 --- /dev/null +++ b/muxplex/frontend/components/store.js @@ -0,0 +1,73 @@ +/** + * Minimal reactive store for shared application state. + * + * Uses EventTarget for change notification. Components subscribe via + * store.on('sessions', callback) and unsubscribe via store.off(). + * + * Usage: + * import { store } from './store.js'; + * store.sessions = [...]; // triggers 'sessions' event + * store.on('sessions', (sessions) => { ... }); + */ +class AppStore extends EventTarget { + #state = { + sessions: [], + viewingSession: null, + viewingRemoteId: '', + viewMode: 'grid', + activeView: 'all', + serverSettings: null, + gridViewMode: 'flat', + deviceId: '', + }; + + /** Get a state value. */ + get(key) { return this.#state[key]; } + + /** Set a state value and dispatch a change event. */ + set(key, value) { + const old = this.#state[key]; + if (old === value) return; + this.#state[key] = value; + this.dispatchEvent(new CustomEvent(key, { detail: { value, old } })); + this.dispatchEvent(new CustomEvent('change', { detail: { key, value, old } })); + } + + /** Batch-set multiple keys; fires one event per key then one 'batch'. */ + batch(updates) { + const changed = []; + for (const [key, value] of Object.entries(updates)) { + const old = this.#state[key]; + if (old !== value) { + this.#state[key] = value; + this.dispatchEvent(new CustomEvent(key, { detail: { value, old } })); + changed.push(key); + } + } + if (changed.length > 0) { + this.dispatchEvent(new CustomEvent('batch', { detail: { keys: changed } })); + } + } + + /** Subscribe to a state key change. Returns unsubscribe function. */ + on(key, fn) { + const handler = (e) => fn(e.detail.value, e.detail.old); + this.addEventListener(key, handler); + return () => this.removeEventListener(key, handler); + } + + /** Remove a specific listener. */ + off(key, fn) { + this.removeEventListener(key, fn); + } + + // Convenience accessors for hot paths + get sessions() { return this.#state.sessions; } + set sessions(v) { this.set('sessions', v); } + get activeView() { return this.#state.activeView; } + set activeView(v) { this.set('activeView', v); } + get serverSettings() { return this.#state.serverSettings; } + set serverSettings(v) { this.set('serverSettings', v); } +} + +export const store = new AppStore(); \ No newline at end of file diff --git a/muxplex/frontend/components/view-dropdown.js b/muxplex/frontend/components/view-dropdown.js new file mode 100644 index 0000000..437a9e1 --- /dev/null +++ b/muxplex/frontend/components/view-dropdown.js @@ -0,0 +1,183 @@ +import { LitElement, html, nothing } from '/vendor/lit/lit.min.js'; +import { repeat } from '/vendor/lit/lit.min.js'; + +/** + * - Unified view-switcher dropdown. + * + * Used in BOTH the header and sidebar (replaces the two parallel implementations). + * + * Properties: + * views: Array - user-created views [{name, sessions}, ...] + * activeView: String - currently active view name ('all', 'hidden', or user view) + * sessions: Array - all sessions (for computing counts) + * settings: Object - serverSettings (for filterVisible) + * variant: String - 'header' | 'sidebar' (affects label rendering) + * open: Boolean - whether the menu is visible + * + * Events: + * view-switch { view } - User selected a view + * view-new-input - User clicked "+ New View" + * view-manage { view } - User clicked "Manage [view]..." + * view-manage-all - User clicked "Manage All Views..." + * dropdown-toggle - Trigger clicked + * dropdown-close - Menu should close + */ +export class ViewDropdown extends LitElement { + static properties = { + views: { type: Array }, + activeView: { type: String, attribute: 'active-view' }, + sessions: { type: Array }, + settings: { type: Object }, + variant: { type: String }, + open: { type: Boolean, reflect: true }, + }; + + // Light DOM -- existing CSS applies + createRenderRoot() { return this; } + + constructor() { + super(); + this.views = []; + this.activeView = 'all'; + this.sessions = []; + this.settings = null; + this.variant = 'header'; + this.open = false; + this._showInput = false; + this._boundDocClick = this._onDocumentClick.bind(this); + } + + updated(changed) { + if (changed.has('open')) { + if (this.open) { + setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0); + } else { + document.removeEventListener('click', this._boundDocClick, true); + this._showInput = false; + } + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._boundDocClick, true); + } + + _onDocumentClick(e) { + if (this.contains(e.target)) return; + if (this._showInput) return; + this.dispatchEvent(new CustomEvent('dropdown-close', { bubbles: true, composed: true })); + } + + /** Compute visible session count for a view name. */ + _countFor(viewName) { + if (typeof window.filterVisible === 'function') { + return window.filterVisible(this.sessions, this.settings, viewName).length; + } + return (this.sessions || []).filter(s => !s.status).length; + } + + get _label() { + if (this.activeView === 'all') return 'All Sessions'; + if (this.activeView === 'hidden') return 'Hidden'; + return this.activeView; + } + + _onTriggerClick() { + this.dispatchEvent(new CustomEvent('dropdown-toggle', { bubbles: true, composed: true })); + } + + _onViewClick(viewName) { + this.dispatchEvent(new CustomEvent('view-switch', { + bubbles: true, composed: true, + detail: { view: viewName }, + })); + } + + _onNewView() { + this._showInput = true; + this.requestUpdate(); + this.dispatchEvent(new CustomEvent('view-new-input', { bubbles: true, composed: true })); + } + + _onManageView() { + this.dispatchEvent(new CustomEvent('view-manage', { + bubbles: true, composed: true, + detail: { view: this.activeView }, + })); + } + + _onManageAll() { + this.dispatchEvent(new CustomEvent('view-manage-all', { bubbles: true, composed: true })); + } + + _renderMenu() { + if (!this.open) return nothing; + + const views = this.views || []; + const allCount = this._countFor('all'); + const hiddenCount = this._countFor('hidden'); + const isUserView = this.activeView !== 'all' && this.activeView !== 'hidden'; + const displayViewName = this.activeView.length > 20 + ? this.activeView.substring(0, 20) + '\u2026' + : this.activeView; + + return html` + + `; + } + + render() { + if (this.variant === 'sidebar') { + return html` + + ${this._renderMenu()} + `; + } + + return html` + + ${this._renderMenu()} + `; + } +} + +customElements.define('view-dropdown', ViewDropdown); diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 18466fc..8c43dbc 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -19,13 +19,7 @@

muxplex

-
- - -
+
@@ -49,13 +43,7 @@
@@ -297,6 +285,8 @@ import '/components/hover-preview.js'; import '/components/bottom-sheet-switcher.js'; import '/components/session-grid.js'; + import '/components/store.js'; + import '/components/view-dropdown.js'; diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 98c9992..773db96 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -1402,42 +1402,42 @@ def test_html_has_search_bar() -> None: def test_view_dropdown_trigger_exists() -> None: - """#view-dropdown-trigger element must exist in the header.""" + """ element must exist in the header.""" soup = _SOUP - trigger = soup.find(id="view-dropdown-trigger") - assert trigger is not None, "Missing #view-dropdown-trigger" - assert trigger.name == "button", ( - f"#view-dropdown-trigger must be a