mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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;
|
||||
}
|
||||
+2
-3
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user