UI polish: update check tooltip date, expand/collapse icons, dialog JS

- NavMenu: show short date in update-check tooltip when last check was not today
- Settings modal: expand/collapse all sections with bi-arrows-expand/collapse icons
- Effective config: add Expand all button (bi-arrows-expand), same icon style as toolbar
- Rename settings-modal.js to dialog.js; add config-option-help.js, JS extension helpers
- Config option help modal and client settings/dialog wiring updates
This commit is contained in:
2026-02-13 00:59:33 +10:00
parent e7c2560d07
commit ecd9e5cc7b
17 changed files with 269 additions and 121 deletions
-1
View File
@@ -23,7 +23,6 @@
<body>
<Routes @rendermode="InteractiveServer" />
<script src="js/ec-option-help.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
@@ -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<IJSObjectReference>("import", "./js/settings-modal.js");
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("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();
}
}
@@ -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;
}
}
@@ -50,19 +50,33 @@
private DotNetObjectReference<ConfigOptionHelpModal>? _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<IJSObjectReference>("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<BoundingRect?>("getAnchorRect", AnchorId);
if (rect is { } r)
{
var rect = await JSRuntime.InvokeAsync<BoundingRect?>("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;";
}
@@ -13,6 +13,9 @@
<div class="ec-toolbar">
<input type="search" id="ec-search-input" class="form-control form-control-sm" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ExpandAll" title="Expand all sections" aria-label="Expand all sections">
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
@@ -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();
@@ -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;
}
}
@@ -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";
}
@@ -28,7 +28,17 @@
@if (SettingsContext == SettingsModalContext.All)
{
<div class="mb-3">
<label class="form-label">Search settings</label>
<div class="d-flex align-items-center gap-2 mb-1">
<label class="form-label mb-0">Search settings</label>
<div class="d-flex gap-1 ms-auto" role="group" aria-label="Expand or collapse all sections">
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Expand all sections" aria-label="Expand all sections" @onclick="ExpandAllGroups">
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Collapse all sections" aria-label="Collapse all sections" @onclick="CollapseAllGroups">
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
</button>
</div>
</div>
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div>
@@ -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<IJSObjectReference>("import", "./js/settings-modal.js");
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("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
/// </summary>
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;
}
@@ -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);
@@ -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
@@ -0,0 +1,64 @@
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Extensions;
/// <summary>
/// Safe JS module interop helpers that swallow disconnect/cancel/dispose exceptions.
/// Use for best-effort or teardown calls (dispose, cleanup, hub callbacks, error toasts).
/// </summary>
public static class JSObjectReferenceExtensions
{
/// <summary>
/// Invokes a void method on the module without throwing on circuit disconnect, cancel, or dispose.
/// Returns true if the call succeeded, false otherwise.
/// </summary>
public static async ValueTask<bool> 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; }
}
/// <summary>
/// Invokes a method on the module that returns a value, without throwing on circuit disconnect, cancel, or dispose.
/// Returns default when the call fails.
/// </summary>
public static async ValueTask<T?> InvokeAsyncSafe<T>(
this IJSObjectReference module,
string identifier,
params object?[]? args)
{
try
{
return await module.InvokeAsync<T>(identifier, args ?? []);
}
catch (JSDisconnectedException) { return default; }
catch (TaskCanceledException) { return default; }
catch (ObjectDisposedException) { return default; }
}
/// <summary>
/// Disposes the module reference without throwing. Use in DisposeAsync when the circuit may already be gone.
/// </summary>
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) { }
}
}
@@ -0,0 +1,56 @@
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Extensions;
/// <summary>
/// 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.
/// </summary>
public static class JSRuntimeExtensions
{
/// <summary>
/// Invokes a void JS function without throwing on circuit disconnect, cancel, or dispose.
/// Returns true if the call succeeded, false otherwise.
/// </summary>
public static async ValueTask<bool> 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; }
}
/// <summary>
/// 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).
/// </summary>
public static async ValueTask<T?> InvokeAsyncSafe<T>(
this IJSRuntime js,
string identifier,
CancellationToken cancellationToken = default,
params object?[]? args)
{
try
{
var a = args ?? [];
if (cancellationToken == default)
return await js.InvokeAsync<T>(identifier, a);
return await js.InvokeAsync<T>(identifier, cancellationToken, a);
}
catch (JSDisconnectedException) { return default; }
catch (TaskCanceledException) { return default; }
catch (ObjectDisposedException) { return default; }
}
}
@@ -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<string?>("getItem");
var json = await module.InvokeAsyncSafe<string?>("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);
}
/// <summary>
@@ -81,11 +82,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
/// </summary>
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;
}
}
@@ -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;
}
@@ -1,7 +1,6 @@
/**
* Native HTML <dialog> helpers for the client settings modal.
* Uses HTMLDialogElement.showModal() / close() — no Bootstrap or other framework.
* Loaded as ES module for IJSObjectReference disposal.
* Native HTML <dialog> helpers (showModal / close / close event).
* Used by SettingsModal and AppLogsFiltersModal. Loaded as ES module for IJSObjectReference disposal.
*/
export function initDialog(dialogElement, dotNetRef) {
@@ -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;
};