diff --git a/src/DnsmasqWebUI/Components/App.razor b/src/DnsmasqWebUI/Components/App.razor index c719156..358e1a7 100644 --- a/src/DnsmasqWebUI/Components/App.razor +++ b/src/DnsmasqWebUI/Components/App.razor @@ -23,7 +23,6 @@ - diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs index 226eb44..3a01791 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsFiltersModal.razor.cs @@ -1,3 +1,4 @@ +using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Infrastructure.Client.Abstractions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -53,7 +54,7 @@ public partial class AppLogsFiltersModal : IAsyncDisposable { try { - _jsModule = await JSRuntime.InvokeAsync("import", "./js/settings-modal.js"); + _jsModule = await JSRuntime.InvokeAsync("import", "./js/dialog.js"); _dotNetRef = DotNetObjectReference.Create(this); _moduleLoaded = true; } @@ -66,17 +67,17 @@ public partial class AppLogsFiltersModal : IAsyncDisposable { if (!_dialogInitialized) { - await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef); + await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef); _dialogInitialized = true; } - await _jsModule.InvokeVoidAsync("showModal", _dialogRef); + await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef); } } private async Task Close() { if (_jsModule != null) - await _jsModule.InvokeVoidAsync("closeModal", _dialogRef); + await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef); await OnClose.InvokeAsync(); } @@ -145,10 +146,6 @@ public partial class AppLogsFiltersModal : IAsyncDisposable _cts.Cancel(); _cts.Dispose(); _dotNetRef?.Dispose(); - if (_jsModule != null) - { - try { await _jsModule.DisposeAsync(); } - catch { /* ignore */ } - } + await _jsModule.DisposeAsyncSafe(); } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs index 435e0e1..fca825e 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogsSection.razor.cs @@ -1,3 +1,4 @@ +using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Infrastructure.Client.Abstractions; using DnsmasqWebUI.Models.Logs; using Microsoft.AspNetCore.Components; @@ -137,19 +138,13 @@ public partial class AppLogsSection : IAsyncDisposable await InvokeAsync(async () => { if (_logsJs == null) return; - try - { - if (payload.Mode == "replace") - await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions); - else - await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions); - _logsContentReceived = true; - SetJustUpdated(); - StateHasChanged(); - } - catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ } - catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ } - catch (JSException) { /* JS error; ignore */ } + if (payload.Mode == "replace") + await _logsJs.InvokeVoidAsyncSafe("replaceLogs", AppLogsPreId, payload.Content, LogsOptions); + else + await _logsJs.InvokeVoidAsyncSafe("appendLogs", AppLogsPreId, payload.Content, LogsOptions); + _logsContentReceived = true; + SetJustUpdated(); + StateHasChanged(); }); } catch (ObjectDisposedException) { /* Component disposed; ignore */ } @@ -183,13 +178,7 @@ public partial class AppLogsSection : IAsyncDisposable await _hubConnection.DisposeAsync(); _hubConnection = null; } - if (_logsJs != null) - { - try { await _logsJs.DisposeAsync(); } - catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); } - catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); } - catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); } - _logsJs = null; - } + await _logsJs.DisposeAsyncSafe(); + _logsJs = null; } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ConfigOptionHelpModal.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ConfigOptionHelpModal.razor index a6eb3db..f1a14b7 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ConfigOptionHelpModal.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/ConfigOptionHelpModal.razor @@ -50,19 +50,33 @@ private DotNetObjectReference? _dotNetRef; private bool _scrollListenerAdded; private bool _mouseOverModal; + private IJSObjectReference? _jsModule; + private bool _moduleLoaded; protected override async Task OnAfterRenderAsync(bool firstRender) { - if (Visible && !_scrollListenerAdded) + if (firstRender) + { + try + { + _jsModule = await JSRuntime.InvokeAsync("import", "./js/config-option-help.js"); + _moduleLoaded = true; + } + catch (InvalidOperationException) { /* prerender */ } + catch (JSDisconnectedException) { /* circuit disconnected */ } + catch (JSException) { /* import failed */ } + } + + if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null) { _scrollListenerAdded = true; _dotNetRef = DotNetObjectReference.Create(this); - await JSRuntime.InvokeVoidAsync("addConfigOptionHelpScrollCloseListener", _dotNetRef); + await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef); } - else if (!Visible && _scrollListenerAdded) + else if (!Visible && _scrollListenerAdded && _jsModule != null) { _scrollListenerAdded = false; - await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener"); + await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener"); _dotNetRef?.Dispose(); _dotNetRef = null; } @@ -79,13 +93,15 @@ public async ValueTask DisposeAsync() { - if (_scrollListenerAdded) + if (_scrollListenerAdded && _jsModule != null) { _scrollListenerAdded = false; - try { await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener"); } catch { /* best effort */ } + await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener"); _dotNetRef?.Dispose(); _dotNetRef = null; } + await _jsModule.DisposeAsyncSafe(); + _jsModule = null; } protected override async Task OnParametersSetAsync() @@ -134,19 +150,16 @@ private async Task PositionUnderAnchorAsync() { - if (string.IsNullOrEmpty(AnchorId)) return; - try + if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return; + var rect = await _jsModule.InvokeAsyncSafe("getAnchorRect", AnchorId); + if (rect is { } r) { - var rect = await JSRuntime.InvokeAsync("getConfigOptionHelpAnchorRect", AnchorId); - if (rect is { } r) - { - var gap = 4; - var topPx = r.Bottom + gap; - var leftPx = r.Left; - _positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;"; - } + var gap = 4; + var topPx = r.Bottom + gap; + var leftPx = r.Left; + _positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;"; } - catch + else { _positionStyle = "visibility: visible;"; } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor index ce6745e..f8d9b18 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor @@ -13,6 +13,9 @@
+ @@ -121,6 +124,13 @@ _openPanels.Add(id); } + private void ExpandAll() + { + foreach (var view in EffectiveConfigViews.GetViewsForContext(Context)) + _openPanels.Add(view.SectionId); + StateHasChanged(); + } + private void CollapseAll() { _openPanels.Clear(); diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs index a61e31c..07c0fdc 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/LogsSection.razor.cs @@ -1,3 +1,4 @@ +using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Logs; using Microsoft.AspNetCore.Components; @@ -123,19 +124,13 @@ public partial class LogsSection : IAsyncDisposable await InvokeAsync(async () => { if (_logsJs == null) return; - try - { - if (payload.Mode == "replace") - await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions); - else - await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions); - _logsContentReceived = true; - SetJustUpdated(); - StateHasChanged(); - } - catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ } - catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ } - catch (JSException) { /* JS error; ignore */ } + if (payload.Mode == "replace") + await _logsJs.InvokeVoidAsyncSafe("replaceLogs", LogsPreId, payload.Content, LogsOptions); + else + await _logsJs.InvokeVoidAsyncSafe("appendLogs", LogsPreId, payload.Content, LogsOptions); + _logsContentReceived = true; + SetJustUpdated(); + StateHasChanged(); }); } catch (ObjectDisposedException) { /* Component disposed; ignore */ } @@ -169,13 +164,7 @@ public partial class LogsSection : IAsyncDisposable await _hubConnection.DisposeAsync(); _hubConnection = null; } - if (_logsJs != null) - { - try { await _logsJs.DisposeAsync(); } - catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); } - catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); } - catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); } - _logsJs = null; - } + await _logsJs.DisposeAsyncSafe(); + _logsJs = null; } } diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index 872242a..56ada22 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -106,7 +106,11 @@ return "Checking…"; var last = UpdateCheckService.LastCheckTime; if (last.HasValue) - return $"No new update available. Checked at {last.Value.ToLocalTime():t}. Check again."; + { + var local = last.Value.ToLocalTime(); + var format = local.Date == DateTime.Now.Date ? "t" : "g"; // time only today, short date+time otherwise + return $"No new update available. Checked at {local.ToString(format)}. Check again."; + } return "Check for updates"; } diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor index d41122e..24f01f5 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor @@ -28,7 +28,17 @@ @if (SettingsContext == SettingsModalContext.All) {
- +
+ +
+ + +
+
diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs index f525673..8c5a8fc 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs @@ -1,3 +1,4 @@ +using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Models.Client; using DnsmasqWebUI.Infrastructure.Services.Abstractions; using Microsoft.AspNetCore.Components; @@ -55,7 +56,7 @@ public partial class SettingsModal : IAsyncDisposable { try { - _jsModule = await JSRuntime.InvokeAsync("import", "./js/settings-modal.js"); + _jsModule = await JSRuntime.InvokeAsync("import", "./js/dialog.js"); _dotNetRef = DotNetObjectReference.Create(this); _moduleLoaded = true; } @@ -68,10 +69,10 @@ public partial class SettingsModal : IAsyncDisposable { if (!_dialogInitialized) { - await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef); + await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef); _dialogInitialized = true; } - await _jsModule.InvokeVoidAsync("showModal", _dialogRef); + await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef); } } @@ -97,6 +98,19 @@ public partial class SettingsModal : IAsyncDisposable StateHasChanged(); } + private void ExpandAllGroups() + { + foreach (var g in GetVisibleGroups()) + _expandedGroupIds.Add(g.Id); + StateHasChanged(); + } + + private void CollapseAllGroups() + { + _expandedGroupIds.Clear(); + StateHasChanged(); + } + private bool ShouldShowSection(string key) { var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext); @@ -125,13 +139,15 @@ public partial class SettingsModal : IAsyncDisposable } await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto()); - await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask); + if (_jsModule != null) + await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef); await OnClose.InvokeAsync(); } private async Task Close() { - await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask); + if (_jsModule != null) + await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef); await OnClose.InvokeAsync(); } @@ -147,14 +163,8 @@ public partial class SettingsModal : IAsyncDisposable /// public async ValueTask DisposeAsync() { - if (_jsModule != null) - { - try { await _jsModule.DisposeAsync(); } - catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); } - catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); } - catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); } - _jsModule = null; - } + await _jsModule.DisposeAsyncSafe(); + _jsModule = null; _dotNetRef?.Dispose(); _dotNetRef = null; } diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.css b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.css index 3a913a5..b0b50ff 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.css +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.css @@ -28,6 +28,15 @@ padding: 1.25rem; } +.settings-expand-collapse-btn { + padding: 0.25rem 0.4rem; + line-height: 1; +} + +.settings-expand-collapse-btn .bi { + font-size: 1rem; +} + .settings-modal-dialog .modal-footer { padding: 1rem 1.25rem; border-top: 1px solid rgba(0, 0, 0, 0.125); diff --git a/src/DnsmasqWebUI/Components/_Imports.razor b/src/DnsmasqWebUI/Components/_Imports.razor index c6d6617..96e0b51 100644 --- a/src/DnsmasqWebUI/Components/_Imports.razor +++ b/src/DnsmasqWebUI/Components/_Imports.razor @@ -7,6 +7,7 @@ @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using DnsmasqWebUI +@using DnsmasqWebUI.Extensions @using DnsmasqWebUI.Models.Client @using DnsmasqWebUI.Models.Client.Abstractions @using DnsmasqWebUI.Infrastructure.Services.Abstractions diff --git a/src/DnsmasqWebUI/Extensions/JSObjectReferenceExtensions.cs b/src/DnsmasqWebUI/Extensions/JSObjectReferenceExtensions.cs new file mode 100644 index 0000000..0c0d42b --- /dev/null +++ b/src/DnsmasqWebUI/Extensions/JSObjectReferenceExtensions.cs @@ -0,0 +1,64 @@ +using Microsoft.JSInterop; + +namespace DnsmasqWebUI.Extensions; + +/// +/// Safe JS module interop helpers that swallow disconnect/cancel/dispose exceptions. +/// Use for best-effort or teardown calls (dispose, cleanup, hub callbacks, error toasts). +/// +public static class JSObjectReferenceExtensions +{ + /// + /// Invokes a void method on the module without throwing on circuit disconnect, cancel, or dispose. + /// Returns true if the call succeeded, false otherwise. + /// + public static async ValueTask InvokeVoidAsyncSafe( + this IJSObjectReference module, + string identifier, + params object?[]? args) + { + try + { + await module.InvokeVoidAsync(identifier, args ?? []); + return true; + } + catch (JSDisconnectedException) { return false; } + catch (TaskCanceledException) { return false; } + catch (ObjectDisposedException) { return false; } + } + + /// + /// Invokes a method on the module that returns a value, without throwing on circuit disconnect, cancel, or dispose. + /// Returns default when the call fails. + /// + public static async ValueTask InvokeAsyncSafe( + this IJSObjectReference module, + string identifier, + params object?[]? args) + { + try + { + return await module.InvokeAsync(identifier, args ?? []); + } + catch (JSDisconnectedException) { return default; } + catch (TaskCanceledException) { return default; } + catch (ObjectDisposedException) { return default; } + } + + /// + /// Disposes the module reference without throwing. Use in DisposeAsync when the circuit may already be gone. + /// + public static async ValueTask DisposeAsyncSafe(this IJSObjectReference? module) + { + if (module == null) return; + try + { + await module.DisposeAsync(); + } + catch (JSDisconnectedException) { } + catch (TaskCanceledException) { } + catch (ObjectDisposedException) { } + catch (InvalidOperationException) { } + catch (JSException) { } + } +} diff --git a/src/DnsmasqWebUI/Extensions/JSRuntimeExtensions.cs b/src/DnsmasqWebUI/Extensions/JSRuntimeExtensions.cs new file mode 100644 index 0000000..f4c9905 --- /dev/null +++ b/src/DnsmasqWebUI/Extensions/JSRuntimeExtensions.cs @@ -0,0 +1,56 @@ +using Microsoft.JSInterop; + +namespace DnsmasqWebUI.Extensions; + +/// +/// Safe JS interop helpers that swallow disconnect/cancel/dispose exceptions. +/// Use for best-effort or teardown calls; use raw InvokeVoidAsync/InvokeAsync when failure must propagate. +/// +public static class JSRuntimeExtensions +{ + /// + /// Invokes a void JS function without throwing on circuit disconnect, cancel, or dispose. + /// Returns true if the call succeeded, false otherwise. + /// + public static async ValueTask InvokeVoidAsyncSafe( + this IJSRuntime js, + string identifier, + CancellationToken cancellationToken = default, + params object?[]? args) + { + try + { + var a = args ?? []; + if (cancellationToken == default) + await js.InvokeVoidAsync(identifier, a); + else + await js.InvokeVoidAsync(identifier, cancellationToken, a); + return true; + } + catch (JSDisconnectedException) { return false; } + catch (TaskCanceledException) { return false; } + catch (ObjectDisposedException) { return false; } + } + + /// + /// Invokes a JS function that returns a value, without throwing on circuit disconnect, cancel, or dispose. + /// Returns default when the call fails (e.g. circuit gone). + /// + public static async ValueTask InvokeAsyncSafe( + this IJSRuntime js, + string identifier, + CancellationToken cancellationToken = default, + params object?[]? args) + { + try + { + var a = args ?? []; + if (cancellationToken == default) + return await js.InvokeAsync(identifier, a); + return await js.InvokeAsync(identifier, cancellationToken, a); + } + catch (JSDisconnectedException) { return default; } + catch (TaskCanceledException) { return default; } + catch (ObjectDisposedException) { return default; } + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/ClientSettingsService.cs b/src/DnsmasqWebUI/Infrastructure/Services/ClientSettingsService.cs index c8cc5de..23af31e 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/ClientSettingsService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/ClientSettingsService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Models.Client; using DnsmasqWebUI.Infrastructure.Services.Abstractions; using Microsoft.JSInterop; @@ -46,7 +47,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos { var module = await GetModuleAsync(); if (module == null) return new ClientSettings(); - var json = await module.InvokeAsync("getItem"); + var json = await module.InvokeAsyncSafe("getItem"); if (string.IsNullOrWhiteSpace(json)) { var defaults = new ClientSettings(); @@ -73,7 +74,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos var module = await GetModuleAsync(); if (module == null) return; var json = JsonSerializer.Serialize(settings, JsonOptions); - await module.InvokeVoidAsync("setItem", json); + await module.InvokeVoidAsyncSafe("setItem", json); } /// @@ -81,11 +82,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos /// public async ValueTask DisposeAsync() { - if (_module == null) return; - try { await _module.DisposeAsync(); } - catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped (prerender)"); } - catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped: circuit disconnected"); } - catch (JSException ex) { _logger.LogDebug(ex, "JS module DisposeAsync failed"); } - finally { _module = null; } + await _module.DisposeAsyncSafe(); + _module = null; } } diff --git a/src/DnsmasqWebUI/wwwroot/js/config-option-help.js b/src/DnsmasqWebUI/wwwroot/js/config-option-help.js new file mode 100644 index 0000000..3f4d332 --- /dev/null +++ b/src/DnsmasqWebUI/wwwroot/js/config-option-help.js @@ -0,0 +1,27 @@ +/** + * Positioning and scroll-close for config option help popover. + * Loaded as ES module by ConfigOptionHelpModal for IJSObjectReference disposal. + */ + +export function getAnchorRect(anchorId) { + const el = document.getElementById(anchorId); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { top: r.top, left: r.left, bottom: r.bottom, width: r.width, height: r.height }; +} + +let _scrollHandler = null; + +export function addScrollCloseListener(dotNetRef) { + if (_scrollHandler) return; + _scrollHandler = function () { + dotNetRef.invokeMethodAsync('OnScrollClose').catch(function () {}); + }; + window.addEventListener('scroll', _scrollHandler, true); +} + +export function removeScrollCloseListener() { + if (!_scrollHandler) return; + window.removeEventListener('scroll', _scrollHandler, true); + _scrollHandler = null; +} diff --git a/src/DnsmasqWebUI/wwwroot/js/settings-modal.js b/src/DnsmasqWebUI/wwwroot/js/dialog.js similarity index 74% rename from src/DnsmasqWebUI/wwwroot/js/settings-modal.js rename to src/DnsmasqWebUI/wwwroot/js/dialog.js index 109bf7e..533071d 100644 --- a/src/DnsmasqWebUI/wwwroot/js/settings-modal.js +++ b/src/DnsmasqWebUI/wwwroot/js/dialog.js @@ -1,7 +1,6 @@ /** - * Native HTML helpers for the client settings modal. - * Uses HTMLDialogElement.showModal() / close() — no Bootstrap or other framework. - * Loaded as ES module for IJSObjectReference disposal. + * Native HTML helpers (showModal / close / close event). + * Used by SettingsModal and AppLogsFiltersModal. Loaded as ES module for IJSObjectReference disposal. */ export function initDialog(dialogElement, dotNetRef) { diff --git a/src/DnsmasqWebUI/wwwroot/js/ec-option-help.js b/src/DnsmasqWebUI/wwwroot/js/ec-option-help.js deleted file mode 100644 index 2f0dcb2..0000000 --- a/src/DnsmasqWebUI/wwwroot/js/ec-option-help.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Positioning and scroll-close for config option help popover. - * Called from ConfigOptionHelpModal.razor via IJSRuntime.InvokeAsync. - */ -window.getConfigOptionHelpAnchorRect = function (anchorId) { - var el = document.getElementById(anchorId); - if (!el) return null; - var r = el.getBoundingClientRect(); - return { top: r.top, left: r.left, bottom: r.bottom, width: r.width, height: r.height }; -}; - -var _configOptionHelpScrollHandler = null; - -window.addConfigOptionHelpScrollCloseListener = function (dotNetRef) { - if (_configOptionHelpScrollHandler) return; - _configOptionHelpScrollHandler = function () { - dotNetRef.invokeMethodAsync('OnScrollClose').catch(function () {}); - }; - window.addEventListener('scroll', _configOptionHelpScrollHandler, true); -}; - -window.removeConfigOptionHelpScrollCloseListener = function () { - if (!_configOptionHelpScrollHandler) return; - window.removeEventListener('scroll', _configOptionHelpScrollHandler, true); - _configOptionHelpScrollHandler = null; -};