From c955aadad1c6752c25bb2241750e5a5201acda65 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Sun, 29 Mar 2026 23:57:22 -0700 Subject: [PATCH] feat: add Sessions settings tab with server-side persistence --- muxplex/frontend/app.js | 127 +++++++++++++++++++ muxplex/frontend/index.html | 29 ++++- muxplex/frontend/style.css | 32 +++++ muxplex/tests/test_frontend_css.py | 47 ++++++++ muxplex/tests/test_frontend_html.py | 85 +++++++++++++ muxplex/tests/test_frontend_js.py | 181 ++++++++++++++++++++++++++++ 6 files changed, 500 insertions(+), 1 deletion(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 9491432..69410f1 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -132,6 +132,7 @@ var _previewSessionName = null; // track by NAME, not DOM element // ─── Settings state ─────────────────────────────────────────────────────────── let _settingsOpen = false; +let _serverSettings = null; const DISPLAY_SETTINGS_KEY = 'muxplex.display'; const DISPLAY_DEFAULTS = { fontSize: 14, @@ -909,6 +910,42 @@ function _setViewingSession(name) { _viewingSession = name; } +// ─── Server settings ───────────────────────────────────────────────────────── + +/** + * Load server settings from GET /api/settings and cache in _serverSettings. + * Always resolves — errors are logged as warnings. + * @returns {Promise} + */ +async function loadServerSettings() { + try { + const res = await api('GET', '/api/settings'); + _serverSettings = await res.json(); + } catch (err) { + console.warn('[loadServerSettings] failed:', err); + if (!_serverSettings) _serverSettings = {}; + } + return _serverSettings; +} + +/** + * Send a PATCH to /api/settings with a single key/value update. + * Shows a toast on success or failure. + * @param {string} key + * @param {*} value + * @returns {Promise} + */ +async function patchServerSetting(key, value) { + try { + await api('PATCH', '/api/settings', { [key]: value }); + _serverSettings = Object.assign({}, _serverSettings, { [key]: value }); + showToast('Setting saved'); + } catch (err) { + showToast('Failed to save setting'); + console.warn('[patchServerSetting] failed:', err); + } +} + // ─── Settings dialog ────────────────────────────────────────────────────────── /** @@ -1000,6 +1037,61 @@ function openSettings() { if (hoverDelayEl) hoverDelayEl.value = String(settings.hoverPreviewDelay); const gridColumnsEl = $('setting-grid-columns'); if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns); + + // Populate Sessions tab from server settings + loadServerSettings().then(function(ss) { + // Default session dropdown + const defaultSessionEl = $('setting-default-session'); + if (defaultSessionEl) { + // Rebuild options from current sessions + defaultSessionEl.innerHTML = ''; + (_currentSessions || []).forEach(function(s) { + const opt = document.createElement('option'); + opt.value = s.name || ''; + opt.textContent = s.name || ''; + if (ss && ss.default_session === s.name) opt.selected = true; + defaultSessionEl.appendChild(opt); + }); + } + + // Sort order + const sortOrderEl = $('setting-sort-order'); + if (sortOrderEl && ss && ss.sort_order) { + sortOrderEl.value = ss.sort_order; + } + + // Hidden sessions checkboxes + const hiddenSessionsEl = $('setting-hidden-sessions'); + if (hiddenSessionsEl) { + hiddenSessionsEl.innerHTML = ''; + const hiddenList = (ss && ss.hidden_sessions) || []; + (_currentSessions || []).forEach(function(s) { + const name = s.name || ''; + const item = document.createElement('label'); + item.className = 'settings-checkbox-item'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'settings-checkbox'; + cb.value = name; + cb.checked = hiddenList.includes(name); + item.appendChild(cb); + item.appendChild(document.createTextNode(' ' + name)); + hiddenSessionsEl.appendChild(item); + }); + } + + // Window size largest + const windowSizeEl = $('setting-window-size-largest'); + if (windowSizeEl) { + windowSizeEl.checked = !!(ss && ss.window_size_largest); + } + + // Auto-open + const autoOpenEl = $('setting-auto-open'); + if (autoOpenEl) { + autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true; + } + }); } /** @@ -1205,6 +1297,38 @@ function bindStaticEventListeners() { on($('setting-font-size'), 'change', onDisplaySettingChange); on($('setting-hover-delay'), 'change', onDisplaySettingChange); on($('setting-grid-columns'), 'change', onDisplaySettingChange); + + // Sessions settings — bind change events for server-side persistence + on($('setting-default-session'), 'change', function() { + var el = $('setting-default-session'); + if (el) patchServerSetting('default_session', el.value); + }); + on($('setting-sort-order'), 'change', function() { + var el = $('setting-sort-order'); + if (el) patchServerSetting('sort_order', el.value); + }); + on($('setting-window-size-largest'), 'change', function() { + var el = $('setting-window-size-largest'); + if (el) patchServerSetting('window_size_largest', el.checked); + }); + on($('setting-auto-open'), 'change', function() { + var el = $('setting-auto-open'); + if (el) patchServerSetting('auto_open', el.checked); + }); + + // Hidden sessions — delegated handler on container (checkboxes are dynamic) + var hiddenSessionsContainer = $('setting-hidden-sessions'); + if (hiddenSessionsContainer) { + hiddenSessionsContainer.addEventListener('change', function(e) { + var cb = e.target.closest('input[type="checkbox"]'); + if (!cb) return; + var hidden = []; + hiddenSessionsContainer.querySelectorAll('input[type="checkbox"]').forEach(function(c) { + if (c.checked) hidden.push(c.value); + }); + patchServerSetting('hidden_sessions', hidden); + }); + } } // ─── Test-only helpers ──────────────────────────────────────────────────────── @@ -1291,6 +1415,9 @@ if (typeof module !== 'undefined' && module.exports) { openSettings, closeSettings, switchSettingsTab, + // Server settings + loadServerSettings, + patchServerSetting, // Test-only helpers _setCurrentSessions, _setViewMode, diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index c030832..e350d68 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -111,7 +111,34 @@ - + diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 1cfd517..5944346 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1059,6 +1059,38 @@ body { } } +/* ============================================================ + Sessions tab controls + ============================================================ */ + +.settings-field--column { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.settings-checkbox-list { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.settings-checkbox-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); +} + +.settings-checkbox { + accent-color: var(--accent); + width: 16px; + height: 16px; + cursor: pointer; +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 0209e91..b26e45b 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -851,3 +851,50 @@ def test_css_settings_mobile_tabs_horizontal(): tabs_block = _extract_rule_block(media_block, ".settings-tabs {") assert "flex-direction: row" in tabs_block, ".settings-tabs must become horizontal on mobile" assert "overflow-x: auto" in tabs_block, ".settings-tabs must scroll horizontally on mobile" + + +# ─── Sessions tab CSS (task-1-sessions-tab) ─────────────────────────────────── + + +def test_css_settings_field_column() -> None: + """.settings-field--column must set flex-direction: column.""" + css = read_css() + assert ".settings-field--column" in css, ( + ".settings-field--column class must be defined in style.css" + ) + # Find the rule and check flex-direction + import re + match = re.search( + r"\.settings-field--column\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-field--column rule not found" + body = match.group(1) + assert "flex-direction" in body and "column" in body, ( + ".settings-field--column must set flex-direction: column" + ) + + +def test_css_settings_checkbox_list() -> None: + """.settings-checkbox-list class must be defined.""" + css = read_css() + assert ".settings-checkbox-list" in css, ( + ".settings-checkbox-list class must be defined in style.css" + ) + + +def test_css_settings_checkbox_item() -> None: + """.settings-checkbox-item class must be defined.""" + css = read_css() + assert ".settings-checkbox-item" in css, ( + ".settings-checkbox-item class must be defined in style.css" + ) + + +def test_css_settings_checkbox() -> None: + """.settings-checkbox class must be defined.""" + css = read_css() + assert ".settings-checkbox" in css, ( + ".settings-checkbox class must be defined in style.css" + ) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 8bf5eb6..e99e612 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -577,3 +577,88 @@ def test_html_settings_tab_panel_data_tab_alignment() -> None: f"Tab buttons {missing_panels} have no matching settings-panel[data-tab=...]. " f"Panel data-tab values found: {panel_values}" ) + + +# ============================================================ +# Sessions tab (task-1-sessions-tab) +# ============================================================ + + +def test_html_sessions_panel_has_default_session_select() -> None: + """Sessions panel must contain a #setting-default-session select.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + assert sessions_panel is not None, "Missing sessions settings-panel" + el = sessions_panel.find(id="setting-default-session") + assert el is not None, "Missing #setting-default-session inside sessions panel" + assert el.name == "select", ( + f"#setting-default-session must be a , got: {el.name}" + ) + options = el.find_all("option") + values = [o.get("value") for o in options] + for v in ("manual", "alphabetical", "recent"): + assert v in values, f"#setting-sort-order missing option value='{v}'" + + +def test_html_sessions_panel_has_hidden_sessions_container() -> None: + """Sessions panel must contain a #setting-hidden-sessions container for checkboxes.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + assert sessions_panel is not None, "Missing sessions settings-panel" + el = sessions_panel.find(id="setting-hidden-sessions") + assert el is not None, "Missing #setting-hidden-sessions inside sessions panel" + + +def test_html_sessions_panel_has_window_size_largest_checkbox() -> None: + """Sessions panel must contain a #setting-window-size-largest checkbox.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + assert sessions_panel is not None, "Missing sessions settings-panel" + el = sessions_panel.find(id="setting-window-size-largest") + assert el is not None, "Missing #setting-window-size-largest inside sessions panel" + assert el.name == "input", ( + f"#setting-window-size-largest must be an , got: {el.name}" + ) + assert el.get("type") == "checkbox", ( + f"#setting-window-size-largest must be type='checkbox', got: {el.get('type')}" + ) + + +def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None: + """Sessions panel must contain a #setting-auto-open checkbox with default checked.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + sessions_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "sessions"}) + assert sessions_panel is not None, "Missing sessions settings-panel" + el = sessions_panel.find(id="setting-auto-open") + assert el is not None, "Missing #setting-auto-open inside sessions panel" + assert el.name == "input", ( + f"#setting-auto-open must be an , got: {el.name}" + ) + assert el.get("type") == "checkbox", ( + f"#setting-auto-open must be type='checkbox', got: {el.get('type')}" + ) + assert el.get("checked") is not None, ( + "#setting-auto-open must be checked by default" + ) diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index dfe385a..80c37ba 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -1116,3 +1116,184 @@ def test_exports_on_display_setting_change() -> None: assert "onDisplaySettingChange" in exports, ( "module.exports must export onDisplaySettingChange" ) + + +# ─── Server settings functions (task-1-sessions-tab) ───────────────────────── + +def test_load_server_settings_function_exists() -> None: + """loadServerSettings function must exist.""" + assert "function loadServerSettings" in _JS, ( + "loadServerSettings must be defined in app.js" + ) + + +def test_load_server_settings_fetches_api_settings() -> None: + """loadServerSettings must fetch GET /api/settings.""" + match = re.search( + r"async function loadServerSettings\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "loadServerSettings function not found" + body = match.group(1) + assert "/api/settings" in body, "loadServerSettings must fetch /api/settings" + assert "GET" in body, "loadServerSettings must use GET method" + + +def test_load_server_settings_caches_in_server_settings() -> None: + """loadServerSettings must cache result in _serverSettings.""" + assert "_serverSettings" in _JS, "_serverSettings variable must exist in app.js" + match = re.search( + r"async function loadServerSettings\s*\(\s*\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "loadServerSettings function not found" + body = match.group(1) + assert "_serverSettings" in body, ( + "loadServerSettings must cache the result in _serverSettings" + ) + + +def test_patch_server_setting_function_exists() -> None: + """patchServerSetting function must exist.""" + assert "function patchServerSetting" in _JS, ( + "patchServerSetting must be defined in app.js" + ) + + +def test_patch_server_setting_sends_patch_request() -> None: + """patchServerSetting must send PATCH to /api/settings.""" + match = re.search( + r"async function patchServerSetting\s*\(.*?\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "patchServerSetting function not found" + body = match.group(1) + assert "/api/settings" in body, "patchServerSetting must send to /api/settings" + assert "PATCH" in body, "patchServerSetting must use PATCH method" + + +def test_patch_server_setting_shows_toast() -> None: + """patchServerSetting must call showToast.""" + match = re.search( + r"async function patchServerSetting\s*\(.*?\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "patchServerSetting function not found" + body = match.group(1) + assert "showToast" in body, "patchServerSetting must call showToast" + + +def test_open_settings_calls_load_server_settings() -> None: + """openSettings must call loadServerSettings to populate sessions tab.""" + match = re.search( + r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "openSettings function not found" + body = match.group(1) + assert "loadServerSettings" in body, ( + "openSettings must call loadServerSettings" + ) + + +def test_bind_static_event_listeners_binds_default_session_change() -> None: + """bindStaticEventListeners must bind change on setting-default-session.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-default-session" in body, ( + "bindStaticEventListeners must bind setting-default-session change" + ) + + +def test_bind_static_event_listeners_binds_sort_order_change() -> None: + """bindStaticEventListeners must bind change on setting-sort-order.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-sort-order" in body, ( + "bindStaticEventListeners must bind setting-sort-order change" + ) + + +def test_bind_static_event_listeners_binds_window_size_largest_change() -> None: + """bindStaticEventListeners must bind change on setting-window-size-largest.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-window-size-largest" in body, ( + "bindStaticEventListeners must bind setting-window-size-largest change" + ) + + +def test_bind_static_event_listeners_binds_auto_open_change() -> None: + """bindStaticEventListeners must bind change on setting-auto-open.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-auto-open" in body, ( + "bindStaticEventListeners must bind setting-auto-open change" + ) + + +def test_bind_static_event_listeners_uses_delegated_handler_for_hidden_sessions() -> None: + """bindStaticEventListeners must use delegated change handler on #setting-hidden-sessions.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "setting-hidden-sessions" in body, ( + "bindStaticEventListeners must use delegated handler on setting-hidden-sessions" + ) + + +def test_exports_load_server_settings() -> None: + """module.exports must export loadServerSettings.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "loadServerSettings" in exports, ( + "module.exports must export loadServerSettings" + ) + + +def test_exports_patch_server_setting() -> None: + """module.exports must export patchServerSetting.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "patchServerSetting" in exports, ( + "module.exports must export patchServerSetting" + )