mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-21 19:32:35 +10:00
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:
@@ -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 <dialog> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user