Refactor: Bootstrap 5.3.8, Bootstrap Icons, Client/Http/Configuration layout

- Update Bootstrap to 5.3.8, add Bootstrap Icons; replace custom SVGs
- Fix settings modal: load editing state only when modal opens (avoid refresh overwrite)
- Home: StatusSection and LogsSection components with own timers
- Move HTTP clients under Client/Http with Abstractions namespace pattern
- Rename Options to Configuration; remove empty Options dir
- Components: Modals/, Home/; consistent Abstractions (Services, Client.Services, Client.Http)
This commit is contained in:
2026-02-01 22:51:05 +10:00
parent e50cfc986c
commit 92dfa2b170
95 changed files with 1246 additions and 636 deletions
@@ -0,0 +1,48 @@
@namespace DnsmasqWebUI.Components.Modals
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
@inject IClientSettingsService ClientSettingsService
@if (IsVisible)
{
<dialog @ref="_dialogRef" class="settings-modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
</div>
<div class="modal-body">
@if (SettingsContext == SettingsModalContext.All)
{
<div class="mb-3">
<label class="form-label">Search settings</label>
<input type="search" class="form-control" placeholder="Search..."
@bind="_searchTerm" @bind:event="oninput" />
</div>
}
@if (ShouldShowSection("ServiceStatus"))
{
<section class="mb-4">
<h6 class="text-muted">Service status</h6>
<label for="status-interval" class="form-label">Polling interval (seconds)</label>
<input id="status-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section>
}
@if (ShouldShowSection("Logs"))
{
<section class="mb-4">
<h6 class="text-muted">Recent logs</h6>
<label for="logs-interval" class="form-label">Polling interval (seconds)</label>
<input id="logs-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Save">Save</button>
</div>
</div>
</dialog>
}
@@ -0,0 +1,134 @@
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Client.Services.Abstractions;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Components.Modals;
/// <summary>
/// Modal for editing client-side settings. Uses native HTML &lt;dialog&gt; via JS interop.
/// Implements IAsyncDisposable: disposes <see cref="IJSObjectReference"/> and <see cref="DotNetObjectReference{T}"/> when the component is removed.
/// </summary>
public partial class SettingsModal : IAsyncDisposable
{
private ElementReference _dialogRef;
private IJSObjectReference? _jsModule;
private DotNetObjectReference<SettingsModal>? _dotNetRef;
private ClientSettings _editingSettings = new();
private string _searchTerm = string.Empty;
private bool _moduleLoaded;
private bool _dialogInitialized;
private bool _wasVisible;
[Parameter] public bool IsVisible { get; set; }
[Parameter] public string Title { get; set; } = "Client settings";
[Parameter] public SettingsModalContext SettingsContext { get; set; } = SettingsModalContext.All;
[Parameter] public EventCallback OnClose { get; set; }
protected override async Task OnParametersSetAsync()
{
var justOpened = IsVisible && !_wasVisible;
if (justOpened)
_dialogInitialized = false;
_wasVisible = IsVisible;
// Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits
if (justOpened)
{
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings
{
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds
};
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
if (IsVisible && _moduleLoaded && _jsModule != null)
{
if (!_dialogInitialized)
{
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
_dialogInitialized = true;
}
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
}
}
private bool ShouldShowSection(string key)
{
if (SettingsContext == SettingsModalContext.ServicePolling)
return key == "ServiceStatus";
if (SettingsContext == SettingsModalContext.LogsPolling)
return key == "Logs";
if (SettingsContext == SettingsModalContext.All)
{
if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
var term = _searchTerm.Trim();
return key switch
{
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
_ => false
};
}
return false;
}
private async Task Save()
{
_editingSettings.ServiceStatusPollingIntervalSeconds = Math.Clamp(
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
private async Task Close()
{
if (_jsModule != null)
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
await OnClose.InvokeAsync();
}
[JSInvokable]
public async Task OnDialogClosed(string returnValue)
{
await OnClose.InvokeAsync();
await InvokeAsync(StateHasChanged);
}
/// <summary>
/// Disposes JS module and DotNetObjectReference. Blazor calls this when the component is removed from the tree.
/// </summary>
public async ValueTask DisposeAsync()
{
try
{
if (_jsModule != null)
await _jsModule.DisposeAsync();
}
finally
{
_jsModule = null;
}
_dotNetRef?.Dispose();
_dotNetRef = null;
}
}
@@ -0,0 +1,35 @@
.settings-modal-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.settings-modal-dialog .modal-content {
max-width: 28rem;
margin: auto;
}
.settings-modal-dialog {
border: none;
border-radius: 0.375rem;
padding: 0;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.settings-modal-dialog .modal-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
}
.settings-modal-dialog .modal-title {
margin: 0;
font-size: 1.25rem;
}
.settings-modal-dialog .modal-body {
padding: 1.25rem;
}
.settings-modal-dialog .modal-footer {
padding: 1rem 1.25rem;
border-top: 1px solid rgba(0, 0, 0, 0.125);
gap: 0.5rem;
}