diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 69410f1..50a1e3b 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1038,6 +1038,29 @@ function openSettings() { const gridColumnsEl = $('setting-grid-columns'); if (gridColumnsEl) gridColumnsEl.value = String(settings.gridColumns); + // Populate Notifications tab from display settings + const bellSoundEl = $('setting-bell-sound'); + if (bellSoundEl) bellSoundEl.checked = !!settings.bellSound; + + // Update notification permission status text/button + const statusEl = $('notification-status-text'); + const reqBtn = $('notification-request-btn'); + if (statusEl && reqBtn) { + if (typeof Notification === 'undefined') { + statusEl.textContent = 'Not supported'; + reqBtn.disabled = true; + } else if (Notification.permission === 'granted') { + statusEl.textContent = 'Granted'; + reqBtn.disabled = true; + } else if (Notification.permission === 'denied') { + statusEl.textContent = 'Denied (check browser settings)'; + reqBtn.disabled = true; + } else { + statusEl.textContent = 'Not requested'; + reqBtn.disabled = false; + } + } + // Populate Sessions tab from server settings loadServerSettings().then(function(ss) { // Default session dropdown @@ -1329,6 +1352,41 @@ function bindStaticEventListeners() { patchServerSetting('hidden_sessions', hidden); }); } + + // Notifications settings — bell sound toggle persists to display settings localStorage + on($('setting-bell-sound'), 'change', function() { + var el = $('setting-bell-sound'); + if (!el) return; + var ds = loadDisplaySettings(); + ds.bellSound = el.checked; + saveDisplaySettings(ds); + }); + + // Notifications settings — permission request button + on($('notification-request-btn'), 'click', function() { + if (typeof Notification === 'undefined') return; + Notification.requestPermission().then(function(permission) { + _notificationPermission = permission; + var ds = loadDisplaySettings(); + ds.notificationPermission = permission; + saveDisplaySettings(ds); + // Update UI state + var statusEl = $('notification-status-text'); + var reqBtn = $('notification-request-btn'); + if (statusEl && reqBtn) { + if (permission === 'granted') { + statusEl.textContent = 'Granted'; + reqBtn.disabled = true; + } else if (permission === 'denied') { + statusEl.textContent = 'Denied (check browser settings)'; + reqBtn.disabled = true; + } else { + statusEl.textContent = 'Not requested'; + reqBtn.disabled = false; + } + } + }); + }); } // ─── Test-only helpers ──────────────────────────────────────────────────────── diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index e350d68..600be8e 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -139,7 +139,19 @@ - + diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 5944346..ec52cb2 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1091,6 +1091,41 @@ body { cursor: pointer; } +/* ============================================================ + Notifications tab controls + ============================================================ */ + +.settings-notification-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.settings-status-text { + font-size: 12px; + color: var(--text-muted); +} + +.settings-action-btn { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + padding: 4px 10px; + cursor: pointer; +} + +.settings-action-btn:hover { + border-color: var(--accent); +} + +.settings-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index b26e45b..576ba7b 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -898,3 +898,116 @@ def test_css_settings_checkbox() -> None: assert ".settings-checkbox" in css, ( ".settings-checkbox class must be defined in style.css" ) + + +# ============================================================ +# Notifications tab CSS (task-2-notifications-tab) +# ============================================================ + + +def test_css_settings_notification_status_exists() -> None: + """.settings-notification-status must exist with flex column, align-items flex-end.""" + import re + css = read_css() + assert ".settings-notification-status" in css, ( + ".settings-notification-status class must be defined in style.css" + ) + match = re.search( + r"\.settings-notification-status\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-notification-status rule not found" + body = match.group(1) + assert "flex-direction" in body and "column" in body, ( + ".settings-notification-status must set flex-direction: column" + ) + assert "align-items" in body and "flex-end" in body, ( + ".settings-notification-status must set align-items: flex-end" + ) + + +def test_css_settings_status_text_exists() -> None: + """.settings-status-text must exist with 12px font-size and text-muted color.""" + import re + css = read_css() + assert ".settings-status-text" in css, ( + ".settings-status-text class must be defined in style.css" + ) + match = re.search( + r"\.settings-status-text\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-status-text rule not found" + body = match.group(1) + assert "font-size: 12px" in body or "font-size:12px" in body, ( + ".settings-status-text must set font-size: 12px" + ) + # Must use text-muted color (either via var(--text-muted) or inline) + assert "var(--text-muted)" in body or "text-muted" in body, ( + ".settings-status-text must use var(--text-muted) color" + ) + + +def test_css_settings_action_btn_exists() -> None: + """.settings-action-btn must exist with background, border, 12px font-size.""" + import re + css = read_css() + assert ".settings-action-btn" in css, ( + ".settings-action-btn class must be defined in style.css" + ) + match = re.search( + r"\.settings-action-btn\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-action-btn rule not found" + body = match.group(1) + assert "font-size: 12px" in body or "font-size:12px" in body, ( + ".settings-action-btn must set font-size: 12px" + ) + assert "border" in body, ( + ".settings-action-btn must have border property" + ) + assert "background" in body, ( + ".settings-action-btn must have background property" + ) + + +def test_css_settings_action_btn_hover_exists() -> None: + """.settings-action-btn:hover must exist with border-color accent.""" + import re + css = read_css() + assert ".settings-action-btn:hover" in css, ( + ".settings-action-btn:hover must be defined in style.css" + ) + match = re.search( + r"\.settings-action-btn:hover\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-action-btn:hover rule not found" + body = match.group(1) + assert "border-color" in body and "var(--accent)" in body, ( + ".settings-action-btn:hover must set border-color: var(--accent)" + ) + + +def test_css_settings_action_btn_disabled_opacity() -> None: + """.settings-action-btn:disabled must have opacity 0.5.""" + import re + css = read_css() + assert ".settings-action-btn:disabled" in css, ( + ".settings-action-btn:disabled must be defined in style.css" + ) + match = re.search( + r"\.settings-action-btn:disabled\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".settings-action-btn:disabled rule not found" + body = match.group(1) + assert "opacity: 0.5" in body or "opacity:0.5" in body, ( + ".settings-action-btn:disabled must set opacity: 0.5" + ) diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index e99e612..1499df3 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -662,3 +662,59 @@ def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None: assert el.get("checked") is not None, ( "#setting-auto-open must be checked by default" ) + + +# ============================================================ +# Notifications tab (task-2-notifications-tab) +# ============================================================ + + +def test_html_notifications_panel_has_bell_sound_checkbox() -> None: + """Notifications panel must contain a #setting-bell-sound checkbox.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"}) + assert notif_panel is not None, "Missing notifications settings-panel" + el = notif_panel.find(id="setting-bell-sound") + assert el is not None, "Missing #setting-bell-sound inside notifications panel" + assert el.name == "input", ( + f"#setting-bell-sound must be an , got: {el.name}" + ) + assert el.get("type") == "checkbox", ( + f"#setting-bell-sound must be type='checkbox', got: {el.get('type')}" + ) + classes = el.get("class") or [] + assert "settings-checkbox" in classes, ( + f"#setting-bell-sound must have class 'settings-checkbox', has: {classes}" + ) + + +def test_html_notifications_panel_has_notification_status_text() -> None: + """Notifications panel must contain #notification-status-text with class settings-status-text.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"}) + assert notif_panel is not None, "Missing notifications settings-panel" + el = notif_panel.find(id="notification-status-text") + assert el is not None, "Missing #notification-status-text inside notifications panel" + classes = el.get("class") or [] + assert "settings-status-text" in classes, ( + f"#notification-status-text must have class 'settings-status-text', has: {classes}" + ) + + +def test_html_notifications_panel_has_request_btn() -> None: + """Notifications panel must contain #notification-request-btn with class settings-action-btn.""" + soup = _SOUP + dialog = soup.find(id="settings-dialog") + assert dialog is not None, "Missing #settings-dialog" + notif_panel = dialog.find(class_="settings-panel", attrs={"data-tab": "notifications"}) + assert notif_panel is not None, "Missing notifications settings-panel" + el = notif_panel.find(id="notification-request-btn") + assert el is not None, "Missing #notification-request-btn inside notifications panel" + classes = el.get("class") or [] + assert "settings-action-btn" in classes, ( + f"#notification-request-btn must have class 'settings-action-btn', has: {classes}" + ) diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 80c37ba..b14a9a7 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -1297,3 +1297,110 @@ def test_exports_patch_server_setting() -> None: assert "patchServerSetting" in exports, ( "module.exports must export patchServerSetting" ) + + +# ─── Notifications tab (task-2-notifications-tab) ───────────────────────────── + +def test_open_settings_populates_bell_sound() -> None: + """openSettings must set setting-bell-sound checkbox from loadDisplaySettings().bellSound.""" + 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 "setting-bell-sound" in body, ( + "openSettings must reference setting-bell-sound to set bell sound checkbox" + ) + assert "bellSound" in body, ( + "openSettings must read bellSound from display settings" + ) + + +def test_open_settings_updates_notification_status_text() -> None: + """openSettings must update notification permission status text and button.""" + 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 "notification-status-text" in body, ( + "openSettings must reference notification-status-text to update permission status" + ) + assert "notification-request-btn" in body, ( + "openSettings must reference notification-request-btn to update button state" + ) + + +def test_open_settings_checks_notification_permission() -> None: + """openSettings must check Notification.permission to update UI.""" + 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 "Notification" in body or "_notificationPermission" in body, ( + "openSettings must check Notification.permission or _notificationPermission" + ) + + +def test_bind_static_event_listeners_binds_bell_sound_change() -> None: + """bindStaticEventListeners must bind change on setting-bell-sound to save to localStorage.""" + 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-bell-sound" in body, ( + "bindStaticEventListeners must bind setting-bell-sound change event" + ) + + +def test_bind_static_event_listeners_bell_sound_saves_to_display_settings() -> None: + """bindStaticEventListeners bell sound change handler must save to display settings.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + # The handler needs to reference saveDisplaySettings and bellSound + assert "saveDisplaySettings" in body or "bellSound" in body, ( + "bindStaticEventListeners bell sound change handler must save to display settings via saveDisplaySettings or bellSound" + ) + + +def test_bind_static_event_listeners_binds_permission_btn() -> None: + """bindStaticEventListeners must bind click on notification-request-btn.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "notification-request-btn" in body, ( + "bindStaticEventListeners must bind notification-request-btn click event" + ) + + +def test_bind_static_event_listeners_permission_btn_calls_request_permission() -> None: + """bindStaticEventListeners permission button handler must call Notification.requestPermission().""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "requestPermission" in body, ( + "bindStaticEventListeners permission button must call Notification.requestPermission()" + )