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
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/config/set.</summary>
public interface IConfigSetClient
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET/PUT api/dhcp/hosts.</summary>
public interface IDhcpHostsClient
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET/PUT api/hosts.</summary>
public interface IHostsClient
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/leases.</summary>
public interface ILeasesClient
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for POST api/reload.</summary>
public interface IReloadClient
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/status.</summary>
public interface IStatusClient
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http;
public sealed class ConfigSetClient : IConfigSetClient
{
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http;
public sealed class DhcpHostsClient : IDhcpHostsClient
{
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http;
public sealed class HostsClient : IHostsClient
{
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http;
public sealed class LeasesClient : ILeasesClient
{
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Services.Abstractions;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http;
public sealed class ReloadClient : IReloadClient
{
@@ -1,7 +1,7 @@
using System.Net;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Http;
namespace DnsmasqWebUI.Client.Http;
/// <summary>
/// DelegatingHandler that rewrites relative request URIs to the current request's scheme, host, and path base.
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using System.Net.Http.Json;
namespace DnsmasqWebUI.Http.Clients;
namespace DnsmasqWebUI.Client.Http;
public sealed class StatusClient : IStatusClient
{
@@ -0,0 +1,13 @@
namespace DnsmasqWebUI.Client.Models;
/// <summary>
/// Client-side preferences persisted in browser local storage.
/// </summary>
public class ClientSettings
{
/// <summary>Polling interval for the service status block (seconds).</summary>
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15;
/// <summary>Polling interval for the recent logs block (seconds).</summary>
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
}
@@ -0,0 +1,16 @@
namespace DnsmasqWebUI.Client.Models;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling
}
@@ -0,0 +1,17 @@
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Services.Abstractions;
namespace DnsmasqWebUI.Client.Services.Abstractions;
/// <summary>
/// Loads and saves client-side preferences (e.g. polling intervals) to browser local storage.
/// Registered as scoped via assembly scanning (<see cref="IApplicationScopedService"/>).
/// </summary>
public interface IClientSettingsService : IApplicationScopedService
{
/// <summary>Loads settings from local storage; returns defaults if missing or invalid.</summary>
Task<ClientSettings> LoadSettingsAsync();
/// <summary>Persists settings to local storage.</summary>
Task SaveSettingsAsync(ClientSettings settings);
}
@@ -0,0 +1,72 @@
using System.Text.Json;
using DnsmasqWebUI.Client.Models;
using DnsmasqWebUI.Client.Services.Abstractions;
using Microsoft.JSInterop;
namespace DnsmasqWebUI.Client.Services;
/// <summary>
/// Loads and saves client-side settings to browser local storage via JS interop.
/// Scoped per Blazor circuit; disposes the JS module when the scope is disposed.
/// </summary>
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
{
private readonly IJSRuntime _jsRuntime;
private IJSObjectReference? _module;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
private async Task<IJSObjectReference> GetModuleAsync()
{
if (_module != null) return _module;
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
"import", "./js/client-settings-storage.js");
return _module;
}
/// <inheritdoc />
public async Task<ClientSettings> LoadSettingsAsync()
{
var module = await GetModuleAsync();
var json = await module.InvokeAsync<string?>("getItem");
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
try
{
return JsonSerializer.Deserialize<ClientSettings>(json, JsonOptions) ?? new ClientSettings();
}
catch
{
return new ClientSettings();
}
}
/// <inheritdoc />
public async Task SaveSettingsAsync(ClientSettings settings)
{
var json = JsonSerializer.Serialize(settings, JsonOptions);
var module = await GetModuleAsync();
await module.InvokeVoidAsync("setItem", json);
}
/// <summary>
/// Disposes the JS module reference. Called when the Blazor circuit scope is disposed.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_module == null) return;
try
{
await _module.DisposeAsync();
}
finally
{
_module = null;
}
}
}
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["lib/bootstrap-icons/bootstrap-icons.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
<ImportMap />
@@ -0,0 +1,37 @@
@namespace DnsmasqWebUI.Components.Home
@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@
@if (_status != null && (_status.LogsConfigured || _status.LogsPath != null))
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<strong>Recent logs</strong>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Recent logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure recent logs polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
</p>
@if (_status.LogsConfigured)
{
<p class="text-muted small mb-1"><code>@_status.LogsCommand</code></p>
}
@if (_status.LogsConfigured)
{
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
}
}
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
}
</div>
}
@@ -0,0 +1,81 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Home;
/// <summary>
/// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself.
/// </summary>
public partial class LogsSection : IDisposable
{
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private int _intervalSeconds;
private readonly CancellationTokenSource _cts = new();
private Timer? _timer;
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
protected override void OnParametersSet()
{
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
private async Task OnRefreshTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshAsync();
StateHasChanged();
}
private async Task RefreshAsync()
{
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
}
catch (OperationCanceledException) { }
catch
{
// Don't overwrite on background refresh
}
finally
{
_refreshing = false;
}
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
_cts.Cancel();
_cts.Dispose();
}
}
@@ -0,0 +1,23 @@
@namespace DnsmasqWebUI.Components.Home
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
@if (_status?.StatusShowConfigured == true)
{
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
<strong>Service status</strong>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
</p>
<p class="text-muted small mb-1"><code>@_status.StatusShowCommand</code></p>
@if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
{
<p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
}
<div class="mt-2">
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
</div>
}
@@ -0,0 +1,81 @@
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Models;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Components.Home;
/// <summary>
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
/// </summary>
public partial class StatusSection : IDisposable
{
private DnsmasqServiceStatus? _status;
private bool _refreshing;
private int _intervalSeconds;
private readonly CancellationTokenSource _cts = new();
private Timer? _timer;
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!;
protected override async Task OnInitializedAsync()
{
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
await RefreshAsync();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
protected override void OnParametersSet()
{
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
if (next == _intervalSeconds) return;
_intervalSeconds = next;
_timer?.Dispose();
_timer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_intervalSeconds),
TimeSpan.FromSeconds(_intervalSeconds));
}
private async Task OnRefreshTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshAsync();
StateHasChanged();
}
private async Task RefreshAsync()
{
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
}
catch (OperationCanceledException) { }
catch
{
// Don't overwrite on background refresh
}
finally
{
_refreshing = false;
}
}
public void Dispose()
{
_timer?.Dispose();
_timer = null;
_cts.Cancel();
_cts.Dispose();
}
}
@@ -3,9 +3,7 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center">
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="Local DNS home">
<span class="brand-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<i class="bi bi-globe brand-icon" aria-hidden="true"></i>
<span>Local DNS</span>
</a>
</div>
@@ -17,17 +15,17 @@
<nav class="nav flex-column" aria-label="Main navigation">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Overview
<i class="bi bi-house-door-fill nav-icon" aria-hidden="true"></i> Overview
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="hosts">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Hosts
<i class="bi bi-list-nested nav-icon" aria-hidden="true"></i> Hosts
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="dhcp">
<span class="bi bi-hdd-network-nav-menu" aria-hidden="true"></span> DHCP
<i class="bi bi-hdd-network nav-icon" aria-hidden="true"></i> DHCP
</NavLink>
</div>
</nav>
@@ -34,38 +34,17 @@
.brand-icon {
display: inline-flex;
flex-shrink: 0;
}
.brand-icon svg {
width: 1.5rem;
height: 1.5rem;
color: rgba(255, 255, 255, 0.95);
font-size: 1.5rem;
vertical-align: -0.125em;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
/* Bootstrap Icons in nav: white, sized to match link text */
.nav-item .nav-icon {
color: rgba(255, 255, 255, 0.95);
font-size: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.bi-hdd-network-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm2 7a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2.5.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1z'/%3E%3Cpath d='M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h1v3H2a2 2 0 0 1-2 2H1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1h-1a2 2 0 0 1-2 2h-3v-3h1a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm13 2h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM4 5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5zm7 4a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V9z'/%3E%3C/svg%3E");
vertical-align: -0.125em;
}
.nav-item {
@@ -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;
}
+4 -1
View File
@@ -13,7 +13,10 @@
@if (_status == null)
{
<p>Loading...</p>
<p class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</p>
}
else
{
+31 -98
View File
@@ -4,6 +4,7 @@
@inject IStatusClient StatusClient
@inject IConfigSetClient ConfigSetClient
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@implements IDisposable
<PageTitle>Overview | Local DNS</PageTitle>
@@ -56,56 +57,10 @@ else if (_status != null)
</div>
}
@* Full service status — output of StatusShowCommand *@
@if (_status.StatusShowConfigured)
{
<p class="mb-1 mt-3"><strong>Service status</strong></p>
<p class="text-muted small mb-1"><code>@_status.StatusShowCommand</code></p>
@if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
{
<p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
}
}
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
@* Recent logs — live view from LogsCommand (show command so it's clear what produces the output) *@
@if (_status.LogsConfigured || _status.LogsPath != null)
{
<p class="mb-1 mt-3"><strong>Recent logs</strong></p>
@if (_status.LogsConfigured)
{
<p class="text-muted small mb-1"><code>@_status.LogsCommand</code> <span class="text-muted">(refreshes every @(_refreshIntervalSeconds)s)</span></p>
}
@if (_status.LogsConfigured)
{
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
}
}
}
@if (_status.StatusShowConfigured || _status.LogsConfigured)
{
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-primary btn-sm" @onclick="RefreshStatus" disabled="@_refreshing">Refresh status &amp; logs</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
}
</div>
}
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section>
<section class="page-section">
@@ -201,19 +156,22 @@ else if (_status != null)
}
@code {
private const int _refreshIntervalSeconds = 15;
private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 15;
private bool _settingsModalOpen;
private string _settingsModalTitle = "Client settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
private ClientSettings _clientSettings = new();
private DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet;
private string? _error;
private bool _loading = true;
private bool _reloading;
private bool _refreshing;
private string? _reloadMessage;
private string? _reloadDetail;
private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new();
private Timer? _refreshTimer;
protected override async Task OnInitializedAsync()
{
@@ -222,6 +180,9 @@ else if (_status != null)
{
await Task.Delay(1, _cts.Token);
var token = _cts.Token;
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
@@ -233,14 +194,6 @@ else if (_status != null)
{
// Config set is optional for overview
}
if (_status.StatusShowConfigured || _status.LogsConfigured)
{
_refreshTimer = new Timer(
_ => _ = InvokeAsync(OnRefreshTick),
null,
TimeSpan.FromSeconds(_refreshIntervalSeconds),
TimeSpan.FromSeconds(_refreshIntervalSeconds));
}
}
}
catch (OperationCanceledException)
@@ -257,43 +210,9 @@ else if (_status != null)
}
}
private async Task OnRefreshTick()
{
if (_cts.Token.IsCancellationRequested) return;
await RefreshStatus();
StateHasChanged();
}
private void OpenServiceStatusSettings() => OpenSettingsModal(SettingsModalContext.ServicePolling, "Service status polling");
private async Task RefreshStatus()
{
if (_status == null) return;
_refreshing = true;
try
{
var token = _cts.Token;
_status = await StatusClient.GetStatusAsync(token);
if (_status != null)
{
try
{
_configSet = await ConfigSetClient.GetConfigSetAsync(token);
}
catch
{
// Config set is optional
}
}
}
catch (OperationCanceledException) { }
catch (Exception)
{
// Don't overwrite _error on background refresh
}
finally
{
_refreshing = false;
}
}
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
private static string GetStatusLabel(DnsmasqServiceStatus s)
{
@@ -356,10 +275,24 @@ else if (_status != null)
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
}
private void OpenSettingsModal(SettingsModalContext context, string title)
{
_settingsModalContext = context;
_settingsModalTitle = title;
_settingsModalOpen = true;
}
private async Task CloseSettingsModal()
{
_settingsModalOpen = false;
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
StateHasChanged();
}
public void Dispose()
{
_refreshTimer?.Dispose();
_refreshTimer = null;
_cts.Cancel();
_cts.Dispose();
}
@@ -10,7 +10,10 @@
@if (_status == null)
{
<p>Loading...</p>
<p class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</p>
}
else if (_status.NoHosts && !_pathInAddnHosts)
{
+5 -1
View File
@@ -7,7 +7,11 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using DnsmasqWebUI
@using DnsmasqWebUI.Client.Models
@using DnsmasqWebUI.Client.Services.Abstractions
@using DnsmasqWebUI.Components
@using DnsmasqWebUI.Components.Modals
@using DnsmasqWebUI.Components.Home
@using DnsmasqWebUI.Models
@using DnsmasqWebUI.Http.Clients
@using DnsmasqWebUI.Client.Http.Abstractions
@using DnsmasqWebUI.Services.Abstractions
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Options;
namespace DnsmasqWebUI.Configuration;
/// <summary>
/// Configuration for dnsmasq paths and reload/status commands.
@@ -20,7 +20,7 @@ public class DnsmasqOptions
/// <summary>Path to the main dnsmasq config (e.g. /etc/dnsmasq.conf). App appends conf-file= at the end if missing so the managed file is included; process must have write access.</summary>
public string MainConfigPath { get; set; } = "";
/// <summary>Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in &lt;main-config-dir&gt;/dnsmasq.d/ and included via conf-file= at the end of the main config so it loads last. Managed file content parsed with DnsmasqConfFileLineParser.</summary>
/// <summary>Filename of the managed config (e.g. zz-dnsmasq-webui.conf), created in the same directory as the main config and included only via a conf-file= directive as the last line of the main config. Managed file content parsed with DnsmasqConfFileLineParser.</summary>
public string ManagedFileName { get; set; } = "zz-dnsmasq-webui.conf";
/// <summary>Optional path to the hosts file the app can edit (e.g. /etc/hosts). When set, the app can read/write it. When no-hosts is not set, the app adds addn-hosts= in the managed file so dnsmasq loads it; when no-hosts is set, dnsmasq only uses addn-hosts files, so this path must be one of the effective addn-hosts paths for editing to take effect. When unset, hosts UI is disabled.</summary>
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Options;
namespace DnsmasqWebUI.Configuration;
/// <summary>
/// Validates required dnsmasq options at startup. If config is missing or default paths don't point at existing files,
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace DnsmasqWebUI.Extensions;
/// <summary>
/// CORS configuration from app settings (Cors:Enabled, Cors:PolicyName, Cors:AllowAnyOrigin, etc.).
/// </summary>
public static class CorsExtensions
{
/// <summary>
/// Adds CORS services and policy when Cors:Enabled is true. Policy name and rules read from configuration.
/// </summary>
public static IServiceCollection AddCorsFromConfiguration(this IServiceCollection services, IConfiguration configuration)
{
if (!configuration.GetValue<bool>("Cors:Enabled"))
return services;
var policyName = configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
var allowAnyOrigin = configuration.GetValue<bool>("Cors:AllowAnyOrigin");
var origins = configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
var methods = configuration.GetSection("Cors:AllowedMethods").Get<string[]>();
var headers = configuration.GetSection("Cors:AllowedHeaders").Get<string[]>();
services.AddCors(options =>
{
options.AddPolicy(policyName, policy =>
{
if (allowAnyOrigin)
policy.AllowAnyOrigin();
else if (origins.Length > 0)
policy.WithOrigins(origins);
if (methods is { Length: > 0 })
policy.WithMethods(methods);
else
policy.AllowAnyMethod();
if (headers is { Length: > 0 })
policy.WithHeaders(headers);
else
policy.AllowAnyHeader();
});
});
return services;
}
/// <summary>
/// Uses CORS middleware when Cors:Enabled is true, with Cors:PolicyName.
/// </summary>
public static IApplicationBuilder UseCorsFromConfiguration(this IApplicationBuilder app, IConfiguration configuration)
{
if (!configuration.GetValue<bool>("Cors:Enabled"))
return app;
var policyName = configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
return app.UseCors(policyName);
}
}
@@ -1,4 +1,6 @@
using System.Reflection;
using DnsmasqWebUI.Client.Http;
using DnsmasqWebUI.Client.Http.Abstractions;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -7,6 +9,31 @@ namespace DnsmasqWebUI.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>Name of the HttpClient used for same-host API calls (status, config, reload, hosts, etc.).</summary>
public const string DnsmasqApiClientName = "DnsmasqWebUI.Api";
/// <summary>
/// Registers the named HttpClient for same-host API calls and all typed API clients
/// (IStatusClient, IConfigSetClient, IReloadClient, IHostsClient, IDhcpHostsClient, ILeasesClient).
/// </summary>
public static IServiceCollection AddDnsmasqApiHttpClients(this IServiceCollection services)
{
services.AddHttpClient(DnsmasqApiClientName, client =>
{
client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute);
})
.AddHttpMessageHandler<SameHostBaseAddressHandler>();
services.AddHttpClient<IStatusClient, StatusClient>(DnsmasqApiClientName);
services.AddHttpClient<IConfigSetClient, ConfigSetClient>(DnsmasqApiClientName);
services.AddHttpClient<IReloadClient, ReloadClient>(DnsmasqApiClientName);
services.AddHttpClient<IHostsClient, HostsClient>(DnsmasqApiClientName);
services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(DnsmasqApiClientName);
services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName);
return services;
}
delegate void RegisterService(IServiceCollection s, Type iface, Type impl);
static readonly (Type MarkerInterface, RegisterService Register)[] ApplicationRegistrations =
@@ -0,0 +1,42 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
namespace DnsmasqWebUI.Extensions;
/// <summary>
/// Middleware configuration from app settings (forwarded headers, HTTPS, CORS).
/// </summary>
public static class WebApplicationExtensions
{
/// <summary>
/// Uses forwarded headers (X-Forwarded-For, X-Forwarded-Proto) when ForwardedHeaders:Enabled is true.
/// </summary>
public static IApplicationBuilder UseForwardedHeadersFromConfiguration(this IApplicationBuilder app, IConfiguration configuration)
{
if (!configuration.GetValue<bool>("ForwardedHeaders:Enabled"))
return app;
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
});
return app;
}
/// <summary>
/// Uses HTTPS redirection and HSTS when Https:UseRedirectAndHsts is true.
/// </summary>
public static IApplicationBuilder UseHttpsFromConfiguration(this IApplicationBuilder app, IConfiguration configuration)
{
if (!configuration.GetValue<bool>("Https:UseRedirectAndHsts"))
return app;
app.UseHttpsRedirection();
app.UseHsts();
return app;
}
}
@@ -10,7 +10,7 @@ namespace DnsmasqWebUI.Models;
/// <param name="AddnHostsPaths">Effective addn-hosts paths dnsmasq loads. Empty when none configured.</param>
/// <param name="EffectiveConfig">Effective dnsmasq config (single-value and flag options) after parsing all config files.</param>
/// <param name="MainConfigPath">Path to the main dnsmasq config file (e.g. /etc/dnsmasq.conf).</param>
/// <param name="ManagedFilePath">Path to the app's managed config file (e.g. dnsmasq.d/zz-dnsmasq-webui.conf).</param>
/// <param name="ManagedFilePath">Path to the app's managed config file (same directory as main config, e.g. zz-dnsmasq-webui.conf).</param>
/// <param name="LeasesPath">Path to the DHCP leases file from effective config (dhcp-leasefile). Null when not configured.</param>
/// <param name="MainConfigPathExists">True if <paramref name="MainConfigPath"/> exists on disk.</param>
/// <param name="ManagedFilePathExists">True if <paramref name="ManagedFilePath"/> exists on disk.</param>
+6 -66
View File
@@ -1,10 +1,6 @@
using System.Net;
using DnsmasqWebUI.Components;
using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Http;
using DnsmasqWebUI.Http.Clients;
using DnsmasqWebUI.Options;
using Microsoft.AspNetCore.HttpOverrides;
using DnsmasqWebUI.Configuration;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
@@ -18,20 +14,7 @@ builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsVa
// ---- Application services ----
builder.Services.AddApplicationServices();
builder.Services.AddHttpContextAccessor();
const string ApiClientName = "DnsmasqWebUI.Api";
builder.Services.AddHttpClient(ApiClientName, client =>
{
client.BaseAddress = new Uri("http://localhost/", UriKind.Absolute);
})
.AddHttpMessageHandler<SameHostBaseAddressHandler>();
builder.Services.AddHttpClient<IStatusClient, StatusClient>(ApiClientName);
builder.Services.AddHttpClient<IConfigSetClient, ConfigSetClient>(ApiClientName);
builder.Services.AddHttpClient<IReloadClient, ReloadClient>(ApiClientName);
builder.Services.AddHttpClient<IHostsClient, HostsClient>(ApiClientName);
builder.Services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(ApiClientName);
builder.Services.AddHttpClient<ILeasesClient, LeasesClient>(ApiClientName);
builder.Services.AddDnsmasqApiHttpClients();
builder.Services.AddControllers()
.AddJsonOptions(o =>
@@ -42,35 +25,7 @@ builder.Services.AddControllers()
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// ---- CORS (optional; Cors:Enabled in appsettings or env) ----
var corsEnabled = builder.Configuration.GetValue<bool>("Cors:Enabled");
if (corsEnabled)
{
var policyName = builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default";
var allowAnyOrigin = builder.Configuration.GetValue<bool>("Cors:AllowAnyOrigin");
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
var methods = builder.Configuration.GetSection("Cors:AllowedMethods").Get<string[]>();
var headers = builder.Configuration.GetSection("Cors:AllowedHeaders").Get<string[]>();
builder.Services.AddCors(options =>
{
options.AddPolicy(policyName, policy =>
{
if (allowAnyOrigin)
policy.AllowAnyOrigin();
else if (origins.Length > 0)
policy.WithOrigins(origins);
if (methods is { Length: > 0 })
policy.WithMethods(methods);
else
policy.AllowAnyMethod();
if (headers is { Length: > 0 })
policy.WithHeaders(headers);
else
policy.AllowAnyHeader();
});
});
}
builder.Services.AddCorsFromConfiguration(builder.Configuration);
var app = builder.Build();
@@ -89,24 +44,9 @@ catch (OptionsValidationException ex)
if (!app.Environment.IsDevelopment())
app.UseExceptionHandler("/Error", createScopeForErrors: true);
if (builder.Configuration.GetValue<bool>("ForwardedHeaders:Enabled"))
{
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
});
}
if (builder.Configuration.GetValue<bool>("Https:UseRedirectAndHsts"))
{
app.UseHttpsRedirection();
app.UseHsts();
}
if (corsEnabled)
app.UseCors(builder.Configuration.GetValue<string>("Cors:PolicyName") ?? "Default");
app.UseForwardedHeadersFromConfiguration(builder.Configuration);
app.UseHttpsFromConfiguration(builder.Configuration);
app.UseCorsFromConfiguration(builder.Configuration);
app.UseAntiforgery();
// ---- Endpoints ----
@@ -1,7 +1,7 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
@@ -1,5 +1,5 @@
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
@@ -128,7 +128,7 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
var mainFull = Path.GetFullPath(mainPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedFilePath = Path.Combine(mainDir, "dnsmasq.d", _options.ManagedFileName);
var managedFilePath = Path.Combine(mainDir, _options.ManagedFileName);
var withSource = DnsmasqConfIncludeParser.GetIncludedPathsWithSource(mainPath);
var files = withSource.Select(p => new DnsmasqConfigSetEntry(
@@ -1,16 +1,19 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Services;
/// <summary>
/// Runs once at startup: ensures the main dnsmasq config ends with a conf-file= line for the managed file
/// (appends it at the end if missing), then creates the managed config file if it does not exist.
/// Requires write access to MainConfigPath and the managed file directory (e.g. root or a user that owns those paths).
/// Runs once at startup: ensures the managed file is included only by a conf-file= directive (never conf-dir=),
/// and that this conf-file= line is the absolute last line of the main config. Removes any existing
/// conf-file= that points to the managed path, trims trailing blank lines, then ensures the file ends with
/// exactly that conf-file= line. Creates the managed config file if it does not exist.
/// Requires write access to MainConfigPath and the managed file directory.
/// </summary>
public class EnsureManagedConfigHostedService : IApplicationHostedService
{
@@ -40,16 +43,16 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
var mainFull = Path.GetFullPath(_options.MainConfigPath);
var mainDir = Path.GetDirectoryName(mainFull) ?? "";
var managedDir = Path.Combine(mainDir, "dnsmasq.d");
var managedPath = Path.Combine(managedDir, _options.ManagedFileName);
var confFileLine = "conf-file=" + Path.Combine("dnsmasq.d", _options.ManagedFileName);
var managedPath = Path.GetFullPath(set.ManagedFilePath);
var managedDir = Path.GetDirectoryName(managedPath) ?? "";
var confFileLine = "conf-file=" + Path.GetRelativePath(mainDir, managedPath);
var lines = File.Exists(mainFull)
? (await File.ReadAllLinesAsync(mainFull, Encoding.UTF8, cancellationToken)).ToList()
: new List<string>();
var toRemove = new List<int>();
for (var i = 0; i < lines.Count; i++)
// Remove any conf-file= line that points to our managed path (we will add it as the last line).
for (var i = lines.Count - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (!trimmed.StartsWith("conf-file=", StringComparison.OrdinalIgnoreCase))
@@ -59,20 +62,25 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
continue;
var resolved = Path.GetFullPath(Path.Combine(mainDir, value));
if (string.Equals(resolved, managedPath, StringComparison.Ordinal))
toRemove.Add(i);
lines.RemoveAt(i);
}
foreach (var i in toRemove.OrderByDescending(x => x))
lines.RemoveAt(i);
// Trim trailing blank lines so the last line is the last non-blank content (or empty).
while (lines.Count > 0 && string.IsNullOrWhiteSpace(lines[^1]))
lines.RemoveAt(lines.Count - 1);
var endsWithConfFile = lines.Count > 0 && lines[^1].Trim().Equals(confFileLine, StringComparison.Ordinal);
if (!endsWithConfFile)
// Ensure the conf-file= line for the managed file is the absolute last line.
if (lines.Count == 0 || !lines[^1].Trim().Equals(confFileLine, StringComparison.Ordinal))
{
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1]))
lines.Add("");
lines.Add(confFileLine);
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
_logger.LogInformation("Appended {Line} to end of main config {Path} so the managed file is included.", confFileLine, mainFull);
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
}
else
{
await File.WriteAllLinesAsync(mainFull, lines, Encoding.UTF8, cancellationToken);
}
Directory.CreateDirectory(managedDir);
@@ -1,6 +1,6 @@
using System.Text;
using DnsmasqWebUI.Models;
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Parsers;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
+1 -1
View File
@@ -1,4 +1,4 @@
using DnsmasqWebUI.Options;
using DnsmasqWebUI.Configuration;
using DnsmasqWebUI.Services.Abstractions;
using Microsoft.Extensions.Options;
+5
View File
@@ -166,3 +166,8 @@ h1:focus {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Settings gear icon size (Bootstrap Icons) */
.bi-gear-sm {
font-size: 1.125rem;
}
@@ -0,0 +1,21 @@
/**
* Local storage access for client settings. Used by IClientSettingsService.
* Loaded as ES module for IJSObjectReference disposal.
*/
const STORAGE_KEY = 'dnsmasq-webui.clientSettings';
export function getItem() {
try {
return window.localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
export function setItem(value) {
try {
window.localStorage.setItem(STORAGE_KEY, value);
} catch (e) {
console.warn('Failed to save client settings:', e);
}
}
@@ -0,0 +1,24 @@
/**
* 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.
*/
export function initDialog(dialogElement, dotNetRef) {
if (!dialogElement) return;
dialogElement.addEventListener('close', function handler() {
dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? '');
});
}
export function showModal(dialogElement) {
if (dialogElement && !dialogElement.open) {
dialogElement.showModal();
}
}
export function closeModal(dialogElement) {
if (dialogElement && dialogElement.open) {
dialogElement.close();
}
}
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap Grid v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap Grid v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
.container,
@@ -73,7 +73,7 @@
}
.col {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-auto > * {
@@ -282,7 +282,7 @@
@media (min-width: 576px) {
.col-sm {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-sm-auto > * {
flex: 0 0 auto;
@@ -451,7 +451,7 @@
}
@media (min-width: 768px) {
.col-md {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-md-auto > * {
flex: 0 0 auto;
@@ -620,7 +620,7 @@
}
@media (min-width: 992px) {
.col-lg {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-lg-auto > * {
flex: 0 0 auto;
@@ -789,7 +789,7 @@
}
@media (min-width: 1200px) {
.col-xl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xl-auto > * {
flex: 0 0 auto;
@@ -958,7 +958,7 @@
}
@media (min-width: 1400px) {
.col-xxl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xxl-auto > * {
flex: 0 0 auto;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap Grid v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap Grid v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
.container,
@@ -73,7 +73,7 @@
}
.col {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-auto > * {
@@ -282,7 +282,7 @@
@media (min-width: 576px) {
.col-sm {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-sm-auto > * {
flex: 0 0 auto;
@@ -451,7 +451,7 @@
}
@media (min-width: 768px) {
.col-md {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-md-auto > * {
flex: 0 0 auto;
@@ -620,7 +620,7 @@
}
@media (min-width: 992px) {
.col-lg {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-lg-auto > * {
flex: 0 0 auto;
@@ -789,7 +789,7 @@
}
@media (min-width: 1200px) {
.col-xl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xl-auto > * {
flex: 0 0 auto;
@@ -958,7 +958,7 @@
}
@media (min-width: 1400px) {
.col-xxl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xxl-auto > * {
flex: 0 0 auto;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap Reboot v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
@@ -516,8 +516,8 @@ legend {
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
@@ -546,6 +546,10 @@ legend + * {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
/* rtl:raw:
[type="tel"],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap Reboot v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
@@ -516,8 +516,8 @@ legend {
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
@@ -546,6 +546,10 @@ legend + * {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
[type="tel"],
[type="url"],
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap Utilities v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap Utilities v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
@@ -519,6 +519,10 @@
.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) {
position: absolute !important;
}
.visually-hidden *,
.visually-hidden-focusable:not(:focus):not(:focus-within) * {
overflow: hidden !important;
}
.stretched-link::after {
position: absolute;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap Utilities v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap Utilities v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
@@ -519,6 +519,10 @@
.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) {
position: absolute !important;
}
.visually-hidden *,
.visually-hidden-focusable:not(:focus):not(:focus-within) * {
overflow: hidden !important;
}
.stretched-link::after {
position: absolute;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+122 -131
View File
@@ -1,7 +1,7 @@
@charset "UTF-8";
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
@@ -517,8 +517,8 @@ legend {
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
@@ -547,6 +547,10 @@ legend + * {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
/* rtl:raw:
[type="tel"],
@@ -601,9 +605,9 @@ progress {
}
.display-1 {
font-size: calc(1.625rem + 4.5vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.625rem + 4.5vw);
}
@media (min-width: 1200px) {
.display-1 {
@@ -612,9 +616,9 @@ progress {
}
.display-2 {
font-size: calc(1.575rem + 3.9vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.575rem + 3.9vw);
}
@media (min-width: 1200px) {
.display-2 {
@@ -623,9 +627,9 @@ progress {
}
.display-3 {
font-size: calc(1.525rem + 3.3vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.525rem + 3.3vw);
}
@media (min-width: 1200px) {
.display-3 {
@@ -634,9 +638,9 @@ progress {
}
.display-4 {
font-size: calc(1.475rem + 2.7vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.475rem + 2.7vw);
}
@media (min-width: 1200px) {
.display-4 {
@@ -645,9 +649,9 @@ progress {
}
.display-5 {
font-size: calc(1.425rem + 2.1vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.425rem + 2.1vw);
}
@media (min-width: 1200px) {
.display-5 {
@@ -656,9 +660,9 @@ progress {
}
.display-6 {
font-size: calc(1.375rem + 1.5vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
.display-6 {
@@ -803,7 +807,7 @@ progress {
}
.col {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-auto > * {
@@ -1012,7 +1016,7 @@ progress {
@media (min-width: 576px) {
.col-sm {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-sm-auto > * {
flex: 0 0 auto;
@@ -1181,7 +1185,7 @@ progress {
}
@media (min-width: 768px) {
.col-md {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-md-auto > * {
flex: 0 0 auto;
@@ -1350,7 +1354,7 @@ progress {
}
@media (min-width: 992px) {
.col-lg {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-lg-auto > * {
flex: 0 0 auto;
@@ -1519,7 +1523,7 @@ progress {
}
@media (min-width: 1200px) {
.col-xl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xl-auto > * {
flex: 0 0 auto;
@@ -1688,7 +1692,7 @@ progress {
}
@media (min-width: 1400px) {
.col-xxl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xxl-auto > * {
flex: 0 0 auto;
@@ -2156,10 +2160,6 @@ progress {
display: block;
padding: 0;
}
.form-control::-moz-placeholder {
color: var(--bs-secondary-color);
opacity: 1;
}
.form-control::placeholder {
color: var(--bs-secondary-color);
opacity: 1;
@@ -2607,9 +2607,11 @@ textarea.form-control-lg {
top: 0;
left: 0;
z-index: 2;
max-width: 100%;
height: 100%;
padding: 1rem 0.75rem;
overflow: hidden;
color: rgba(var(--bs-body-color-rgb), 0.65);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2627,17 +2629,10 @@ textarea.form-control-lg {
.form-floating > .form-control-plaintext {
padding: 1rem 0.75rem;
}
.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder {
color: transparent;
}
.form-floating > .form-control::placeholder,
.form-floating > .form-control-plaintext::placeholder {
color: transparent;
}
.form-floating > .form-control:not(:-moz-placeholder-shown), .form-floating > .form-control-plaintext:not(:-moz-placeholder-shown) {
padding-top: 1.625rem;
padding-bottom: 0.625rem;
}
.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown),
.form-floating > .form-control-plaintext:focus,
.form-floating > .form-control-plaintext:not(:placeholder-shown) {
@@ -2652,43 +2647,30 @@ textarea.form-control-lg {
.form-floating > .form-select {
padding-top: 1.625rem;
padding-bottom: 0.625rem;
}
.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label {
color: rgba(var(--bs-body-color-rgb), 0.65);
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
padding-left: 0.75rem;
}
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label,
.form-floating > .form-control-plaintext ~ label,
.form-floating > .form-select ~ label {
color: rgba(var(--bs-body-color-rgb), 0.65);
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
z-index: -1;
height: 1.5em;
content: "";
background-color: var(--bs-body-bg);
border-radius: var(--bs-border-radius);
}
.form-floating > .form-control:focus ~ label::after,
.form-floating > .form-control:not(:placeholder-shown) ~ label::after,
.form-floating > .form-control-plaintext ~ label::after,
.form-floating > .form-select ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
z-index: -1;
height: 1.5em;
content: "";
background-color: var(--bs-body-bg);
border-radius: var(--bs-border-radius);
}
.form-floating > .form-control:-webkit-autofill ~ label {
color: rgba(var(--bs-body-color-rgb), 0.65);
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
.form-floating > textarea:focus ~ label::after,
.form-floating > textarea:not(:placeholder-shown) ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
z-index: -1;
height: 1.5em;
content: "";
background-color: var(--bs-body-bg);
border-radius: var(--bs-border-radius);
}
.form-floating > textarea:disabled ~ label::after {
background-color: var(--bs-secondary-bg);
}
.form-floating > .form-control-plaintext ~ label {
border-width: var(--bs-border-width) 0;
}
@@ -2696,10 +2678,6 @@ textarea.form-control-lg {
.form-floating > .form-control:disabled ~ label {
color: #6c757d;
}
.form-floating > :disabled ~ label::after,
.form-floating > .form-control:disabled ~ label::after {
background-color: var(--bs-secondary-bg);
}
.input-group {
position: relative;
@@ -2782,7 +2760,7 @@ textarea.form-control-lg {
border-bottom-right-radius: 0;
}
.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {
margin-left: calc(var(--bs-border-width) * -1);
margin-left: calc(-1 * var(--bs-border-width));
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
@@ -2824,7 +2802,7 @@ textarea.form-control-lg {
.was-validated .form-control:valid, .form-control.is-valid {
border-color: var(--bs-form-valid-border-color);
padding-right: calc(1.5em + 0.75rem);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
@@ -2843,7 +2821,7 @@ textarea.form-control-lg {
border-color: var(--bs-form-valid-border-color);
}
.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] {
--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");
padding-right: 4.125rem;
background-position: right 0.75rem center, center right 2.25rem;
background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
@@ -3755,7 +3733,7 @@ textarea.form-control-lg {
}
.btn-group > :not(.btn-check:first-child) + .btn,
.btn-group > .btn-group:not(:first-child) {
margin-left: calc(var(--bs-border-width) * -1);
margin-left: calc(-1 * var(--bs-border-width));
}
.btn-group > .btn:not(:last-child):not(.dropdown-toggle),
.btn-group > .btn.dropdown-toggle-split:first-child,
@@ -3802,14 +3780,15 @@ textarea.form-control-lg {
}
.btn-group-vertical > .btn:not(:first-child),
.btn-group-vertical > .btn-group:not(:first-child) {
margin-top: calc(var(--bs-border-width) * -1);
margin-top: calc(-1 * var(--bs-border-width));
}
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),
.btn-group-vertical > .btn-group:not(:last-child) > .btn {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.btn-group-vertical > .btn ~ .btn,
.btn-group-vertical > .btn:nth-child(n+3),
.btn-group-vertical > :not(.btn-check) + .btn,
.btn-group-vertical > .btn-group:not(:first-child) > .btn {
border-top-left-radius: 0;
border-top-right-radius: 0;
@@ -3933,8 +3912,8 @@ textarea.form-control-lg {
.nav-justified > .nav-link,
.nav-justified .nav-item {
flex-basis: 0;
flex-grow: 1;
flex-basis: 0;
text-align: center;
}
@@ -4035,8 +4014,8 @@ textarea.form-control-lg {
}
.navbar-collapse {
flex-basis: 100%;
flex-grow: 1;
flex-basis: 100%;
align-items: center;
}
@@ -4531,7 +4510,7 @@ textarea.form-control-lg {
flex-flow: row wrap;
}
.card-group > .card {
flex: 1 0 0%;
flex: 1 0 0;
margin-bottom: 0;
}
.card-group > .card + .card {
@@ -4542,24 +4521,24 @@ textarea.form-control-lg {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.card-group > .card:not(:last-child) .card-img-top,
.card-group > .card:not(:last-child) .card-header {
.card-group > .card:not(:last-child) > .card-img-top,
.card-group > .card:not(:last-child) > .card-header {
border-top-right-radius: 0;
}
.card-group > .card:not(:last-child) .card-img-bottom,
.card-group > .card:not(:last-child) .card-footer {
.card-group > .card:not(:last-child) > .card-img-bottom,
.card-group > .card:not(:last-child) > .card-footer {
border-bottom-right-radius: 0;
}
.card-group > .card:not(:first-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.card-group > .card:not(:first-child) .card-img-top,
.card-group > .card:not(:first-child) .card-header {
.card-group > .card:not(:first-child) > .card-img-top,
.card-group > .card:not(:first-child) > .card-header {
border-top-left-radius: 0;
}
.card-group > .card:not(:first-child) .card-img-bottom,
.card-group > .card:not(:first-child) .card-footer {
.card-group > .card:not(:first-child) > .card-img-bottom,
.card-group > .card:not(:first-child) > .card-footer {
border-bottom-left-radius: 0;
}
}
@@ -4576,11 +4555,11 @@ textarea.form-control-lg {
--bs-accordion-btn-padding-y: 1rem;
--bs-accordion-btn-color: var(--bs-body-color);
--bs-accordion-btn-bg: var(--bs-accordion-bg);
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
--bs-accordion-btn-icon-width: 1.25rem;
--bs-accordion-btn-icon-transform: rotate(-180deg);
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
--bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
--bs-accordion-body-padding-x: 1.25rem;
--bs-accordion-body-padding-y: 1rem;
@@ -4690,16 +4669,15 @@ textarea.form-control-lg {
.accordion-flush > .accordion-item:last-child {
border-bottom: 0;
}
.accordion-flush > .accordion-item > .accordion-header .accordion-button, .accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed {
border-radius: 0;
}
.accordion-flush > .accordion-item > .accordion-collapse {
.accordion-flush > .accordion-item > .accordion-collapse,
.accordion-flush > .accordion-item > .accordion-header .accordion-button,
.accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed {
border-radius: 0;
}
[data-bs-theme=dark] .accordion-button::after {
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
}
.breadcrumb {
@@ -4803,7 +4781,7 @@ textarea.form-control-lg {
}
.page-item:not(:first-child) .page-link {
margin-left: calc(var(--bs-border-width) * -1);
margin-left: calc(-1 * var(--bs-border-width));
}
.page-item:first-child .page-link {
border-top-left-radius: var(--bs-pagination-border-radius);
@@ -4952,7 +4930,7 @@ textarea.form-control-lg {
@keyframes progress-bar-stripes {
0% {
background-position-x: 1rem;
background-position-x: var(--bs-progress-height);
}
}
.progress,
@@ -5046,22 +5024,6 @@ textarea.form-control-lg {
counter-increment: section;
}
.list-group-item-action {
width: 100%;
color: var(--bs-list-group-action-color);
text-align: inherit;
}
.list-group-item-action:hover, .list-group-item-action:focus {
z-index: 1;
color: var(--bs-list-group-action-hover-color);
text-decoration: none;
background-color: var(--bs-list-group-action-hover-bg);
}
.list-group-item-action:active {
color: var(--bs-list-group-action-active-color);
background-color: var(--bs-list-group-action-active-bg);
}
.list-group-item {
position: relative;
display: block;
@@ -5098,6 +5060,22 @@ textarea.form-control-lg {
border-top-width: var(--bs-list-group-border-width);
}
.list-group-item-action {
width: 100%;
color: var(--bs-list-group-action-color);
text-align: inherit;
}
.list-group-item-action:not(.active):hover, .list-group-item-action:not(.active):focus {
z-index: 1;
color: var(--bs-list-group-action-hover-color);
text-decoration: none;
background-color: var(--bs-list-group-action-hover-bg);
}
.list-group-item-action:not(.active):active {
color: var(--bs-list-group-action-active-color);
background-color: var(--bs-list-group-action-active-bg);
}
.list-group-horizontal {
flex-direction: row;
}
@@ -5357,19 +5335,19 @@ textarea.form-control-lg {
.btn-close {
--bs-btn-close-color: #000;
--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");
--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");
--bs-btn-close-opacity: 0.5;
--bs-btn-close-hover-opacity: 0.75;
--bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
--bs-btn-close-focus-opacity: 1;
--bs-btn-close-disabled-opacity: 0.25;
--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
box-sizing: content-box;
width: 1em;
height: 1em;
padding: 0.25em 0.25em;
color: var(--bs-btn-close-color);
background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat;
filter: var(--bs-btn-close-filter);
border: 0;
border-radius: 0.375rem;
opacity: var(--bs-btn-close-opacity);
@@ -5393,11 +5371,16 @@ textarea.form-control-lg {
}
.btn-close-white {
filter: var(--bs-btn-close-white-filter);
--bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%);
}
[data-bs-theme=dark] .btn-close {
filter: var(--bs-btn-close-white-filter);
:root,
[data-bs-theme=light] {
--bs-btn-close-filter: ;
}
[data-bs-theme=dark] {
--bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%);
}
.toast {
@@ -5474,7 +5457,7 @@ textarea.form-control-lg {
--bs-modal-width: 500px;
--bs-modal-padding: 1rem;
--bs-modal-margin: 0.5rem;
--bs-modal-color: ;
--bs-modal-color: var(--bs-body-color);
--bs-modal-bg: var(--bs-body-bg);
--bs-modal-border-color: var(--bs-border-color-translucent);
--bs-modal-border-width: var(--bs-border-width);
@@ -5510,8 +5493,8 @@ textarea.form-control-lg {
pointer-events: none;
}
.modal.fade .modal-dialog {
transition: transform 0.3s ease-out;
transform: translate(0, -50px);
transition: transform 0.3s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modal.fade .modal-dialog {
@@ -5586,7 +5569,10 @@ textarea.form-control-lg {
}
.modal-header .btn-close {
padding: calc(var(--bs-modal-header-padding-y) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5);
margin: calc(-0.5 * var(--bs-modal-header-padding-y)) calc(-0.5 * var(--bs-modal-header-padding-x)) calc(-0.5 * var(--bs-modal-header-padding-y)) auto;
margin-top: calc(-0.5 * var(--bs-modal-header-padding-y));
margin-right: calc(-0.5 * var(--bs-modal-header-padding-x));
margin-bottom: calc(-0.5 * var(--bs-modal-header-padding-y));
margin-left: auto;
}
.modal-title {
@@ -6107,6 +6093,7 @@ textarea.form-control-lg {
color: #fff;
text-align: center;
background: none;
filter: var(--bs-carousel-control-icon-filter);
border: 0;
opacity: 0.5;
transition: opacity 0.15s ease;
@@ -6145,11 +6132,11 @@ textarea.form-control-lg {
}
.carousel-control-prev-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")*/;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")*/;
}
.carousel-control-next-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")*/;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e") /*rtl:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")*/;
}
.carousel-indicators {
@@ -6175,7 +6162,7 @@ textarea.form-control-lg {
margin-left: 3px;
text-indent: -999px;
cursor: pointer;
background-color: #fff;
background-color: var(--bs-carousel-indicator-active-bg);
background-clip: padding-box;
border: 0;
border-top: 10px solid transparent;
@@ -6199,36 +6186,33 @@ textarea.form-control-lg {
left: 15%;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
color: #fff;
color: var(--bs-carousel-caption-color);
text-align: center;
}
.carousel-dark .carousel-control-prev-icon,
.carousel-dark .carousel-control-next-icon {
filter: invert(1) grayscale(100);
}
.carousel-dark .carousel-indicators [data-bs-target] {
background-color: #000;
}
.carousel-dark .carousel-caption {
color: #000;
.carousel-dark {
--bs-carousel-indicator-active-bg: #000;
--bs-carousel-caption-color: #000;
--bs-carousel-control-icon-filter: invert(1) grayscale(100);
}
[data-bs-theme=dark] .carousel .carousel-control-prev-icon,
[data-bs-theme=dark] .carousel .carousel-control-next-icon, [data-bs-theme=dark].carousel .carousel-control-prev-icon,
[data-bs-theme=dark].carousel .carousel-control-next-icon {
filter: invert(1) grayscale(100);
:root,
[data-bs-theme=light] {
--bs-carousel-indicator-active-bg: #fff;
--bs-carousel-caption-color: #fff;
--bs-carousel-control-icon-filter: ;
}
[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target], [data-bs-theme=dark].carousel .carousel-indicators [data-bs-target] {
background-color: #000;
}
[data-bs-theme=dark] .carousel .carousel-caption, [data-bs-theme=dark].carousel .carousel-caption {
color: #000;
[data-bs-theme=dark] {
--bs-carousel-indicator-active-bg: #000;
--bs-carousel-caption-color: #000;
--bs-carousel-control-icon-filter: invert(1) grayscale(100);
}
.spinner-grow,
.spinner-border {
display: inline-block;
flex-shrink: 0;
width: var(--bs-spinner-width);
height: var(--bs-spinner-height);
vertical-align: var(--bs-spinner-vertical-align);
@@ -6773,7 +6757,10 @@ textarea.form-control-lg {
}
.offcanvas-header .btn-close {
padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5);
margin: calc(-0.5 * var(--bs-offcanvas-padding-y)) calc(-0.5 * var(--bs-offcanvas-padding-x)) calc(-0.5 * var(--bs-offcanvas-padding-y)) auto;
margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y));
margin-right: calc(-0.5 * var(--bs-offcanvas-padding-x));
margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y));
margin-left: auto;
}
.offcanvas-title {
@@ -7174,6 +7161,10 @@ textarea.form-control-lg {
.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) {
position: absolute !important;
}
.visually-hidden *,
.visually-hidden-focusable:not(:focus):not(:focus-within) * {
overflow: hidden !important;
}
.stretched-link::after {
position: absolute;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
@charset "UTF-8";
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
@@ -517,8 +517,8 @@ legend {
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
legend {
@@ -547,6 +547,10 @@ legend + * {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-cancel-button {
cursor: pointer;
filter: grayscale(1);
}
[type="tel"],
[type="url"],
@@ -599,9 +603,9 @@ progress {
}
.display-1 {
font-size: calc(1.625rem + 4.5vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.625rem + 4.5vw);
}
@media (min-width: 1200px) {
.display-1 {
@@ -610,9 +614,9 @@ progress {
}
.display-2 {
font-size: calc(1.575rem + 3.9vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.575rem + 3.9vw);
}
@media (min-width: 1200px) {
.display-2 {
@@ -621,9 +625,9 @@ progress {
}
.display-3 {
font-size: calc(1.525rem + 3.3vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.525rem + 3.3vw);
}
@media (min-width: 1200px) {
.display-3 {
@@ -632,9 +636,9 @@ progress {
}
.display-4 {
font-size: calc(1.475rem + 2.7vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.475rem + 2.7vw);
}
@media (min-width: 1200px) {
.display-4 {
@@ -643,9 +647,9 @@ progress {
}
.display-5 {
font-size: calc(1.425rem + 2.1vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.425rem + 2.1vw);
}
@media (min-width: 1200px) {
.display-5 {
@@ -654,9 +658,9 @@ progress {
}
.display-6 {
font-size: calc(1.375rem + 1.5vw);
font-weight: 300;
line-height: 1.2;
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
.display-6 {
@@ -801,7 +805,7 @@ progress {
}
.col {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-auto > * {
@@ -1010,7 +1014,7 @@ progress {
@media (min-width: 576px) {
.col-sm {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-sm-auto > * {
flex: 0 0 auto;
@@ -1179,7 +1183,7 @@ progress {
}
@media (min-width: 768px) {
.col-md {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-md-auto > * {
flex: 0 0 auto;
@@ -1348,7 +1352,7 @@ progress {
}
@media (min-width: 992px) {
.col-lg {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-lg-auto > * {
flex: 0 0 auto;
@@ -1517,7 +1521,7 @@ progress {
}
@media (min-width: 1200px) {
.col-xl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xl-auto > * {
flex: 0 0 auto;
@@ -1686,7 +1690,7 @@ progress {
}
@media (min-width: 1400px) {
.col-xxl {
flex: 1 0 0%;
flex: 1 0 0;
}
.row-cols-xxl-auto > * {
flex: 0 0 auto;
@@ -2154,10 +2158,6 @@ progress {
display: block;
padding: 0;
}
.form-control::-moz-placeholder {
color: var(--bs-secondary-color);
opacity: 1;
}
.form-control::placeholder {
color: var(--bs-secondary-color);
opacity: 1;
@@ -2605,9 +2605,11 @@ textarea.form-control-lg {
top: 0;
right: 0;
z-index: 2;
max-width: 100%;
height: 100%;
padding: 1rem 0.75rem;
overflow: hidden;
color: rgba(var(--bs-body-color-rgb), 0.65);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
@@ -2625,17 +2627,10 @@ textarea.form-control-lg {
.form-floating > .form-control-plaintext {
padding: 1rem 0.75rem;
}
.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder {
color: transparent;
}
.form-floating > .form-control::placeholder,
.form-floating > .form-control-plaintext::placeholder {
color: transparent;
}
.form-floating > .form-control:not(:-moz-placeholder-shown), .form-floating > .form-control-plaintext:not(:-moz-placeholder-shown) {
padding-top: 1.625rem;
padding-bottom: 0.625rem;
}
.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown),
.form-floating > .form-control-plaintext:focus,
.form-floating > .form-control-plaintext:not(:placeholder-shown) {
@@ -2650,43 +2645,30 @@ textarea.form-control-lg {
.form-floating > .form-select {
padding-top: 1.625rem;
padding-bottom: 0.625rem;
}
.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label {
color: rgba(var(--bs-body-color-rgb), 0.65);
transform: scale(0.85) translateY(-0.5rem) translateX(-0.15rem);
padding-right: 0.75rem;
}
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label,
.form-floating > .form-control-plaintext ~ label,
.form-floating > .form-select ~ label {
color: rgba(var(--bs-body-color-rgb), 0.65);
transform: scale(0.85) translateY(-0.5rem) translateX(-0.15rem);
}
.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
z-index: -1;
height: 1.5em;
content: "";
background-color: var(--bs-body-bg);
border-radius: var(--bs-border-radius);
}
.form-floating > .form-control:focus ~ label::after,
.form-floating > .form-control:not(:placeholder-shown) ~ label::after,
.form-floating > .form-control-plaintext ~ label::after,
.form-floating > .form-select ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
z-index: -1;
height: 1.5em;
content: "";
background-color: var(--bs-body-bg);
border-radius: var(--bs-border-radius);
}
.form-floating > .form-control:-webkit-autofill ~ label {
color: rgba(var(--bs-body-color-rgb), 0.65);
transform: scale(0.85) translateY(-0.5rem) translateX(-0.15rem);
}
.form-floating > textarea:focus ~ label::after,
.form-floating > textarea:not(:placeholder-shown) ~ label::after {
position: absolute;
inset: 1rem 0.375rem;
z-index: -1;
height: 1.5em;
content: "";
background-color: var(--bs-body-bg);
border-radius: var(--bs-border-radius);
}
.form-floating > textarea:disabled ~ label::after {
background-color: var(--bs-secondary-bg);
}
.form-floating > .form-control-plaintext ~ label {
border-width: var(--bs-border-width) 0;
}
@@ -2694,10 +2676,6 @@ textarea.form-control-lg {
.form-floating > .form-control:disabled ~ label {
color: #6c757d;
}
.form-floating > :disabled ~ label::after,
.form-floating > .form-control:disabled ~ label::after {
background-color: var(--bs-secondary-bg);
}
.input-group {
position: relative;
@@ -2780,7 +2758,7 @@ textarea.form-control-lg {
border-bottom-left-radius: 0;
}
.input-group > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) {
margin-right: calc(var(--bs-border-width) * -1);
margin-right: calc(-1 * var(--bs-border-width));
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
@@ -2822,7 +2800,7 @@ textarea.form-control-lg {
.was-validated .form-control:valid, .form-control.is-valid {
border-color: var(--bs-form-valid-border-color);
padding-left: calc(1.5em + 0.75rem);
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: left calc(0.375em + 0.1875rem) center;
background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
@@ -2841,7 +2819,7 @@ textarea.form-control-lg {
border-color: var(--bs-form-valid-border-color);
}
.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] {
--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
--bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");
padding-left: 4.125rem;
background-position: left 0.75rem center, center left 2.25rem;
background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem);
@@ -3753,7 +3731,7 @@ textarea.form-control-lg {
}
.btn-group > :not(.btn-check:first-child) + .btn,
.btn-group > .btn-group:not(:first-child) {
margin-right: calc(var(--bs-border-width) * -1);
margin-right: calc(-1 * var(--bs-border-width));
}
.btn-group > .btn:not(:last-child):not(.dropdown-toggle),
.btn-group > .btn.dropdown-toggle-split:first-child,
@@ -3800,14 +3778,15 @@ textarea.form-control-lg {
}
.btn-group-vertical > .btn:not(:first-child),
.btn-group-vertical > .btn-group:not(:first-child) {
margin-top: calc(var(--bs-border-width) * -1);
margin-top: calc(-1 * var(--bs-border-width));
}
.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle),
.btn-group-vertical > .btn-group:not(:last-child) > .btn {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.btn-group-vertical > .btn ~ .btn,
.btn-group-vertical > .btn:nth-child(n+3),
.btn-group-vertical > :not(.btn-check) + .btn,
.btn-group-vertical > .btn-group:not(:first-child) > .btn {
border-top-right-radius: 0;
border-top-left-radius: 0;
@@ -3931,8 +3910,8 @@ textarea.form-control-lg {
.nav-justified > .nav-link,
.nav-justified .nav-item {
flex-basis: 0;
flex-grow: 1;
flex-basis: 0;
text-align: center;
}
@@ -4033,8 +4012,8 @@ textarea.form-control-lg {
}
.navbar-collapse {
flex-basis: 100%;
flex-grow: 1;
flex-basis: 100%;
align-items: center;
}
@@ -4529,7 +4508,7 @@ textarea.form-control-lg {
flex-flow: row wrap;
}
.card-group > .card {
flex: 1 0 0%;
flex: 1 0 0;
margin-bottom: 0;
}
.card-group > .card + .card {
@@ -4540,24 +4519,24 @@ textarea.form-control-lg {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.card-group > .card:not(:last-child) .card-img-top,
.card-group > .card:not(:last-child) .card-header {
.card-group > .card:not(:last-child) > .card-img-top,
.card-group > .card:not(:last-child) > .card-header {
border-top-left-radius: 0;
}
.card-group > .card:not(:last-child) .card-img-bottom,
.card-group > .card:not(:last-child) .card-footer {
.card-group > .card:not(:last-child) > .card-img-bottom,
.card-group > .card:not(:last-child) > .card-footer {
border-bottom-left-radius: 0;
}
.card-group > .card:not(:first-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.card-group > .card:not(:first-child) .card-img-top,
.card-group > .card:not(:first-child) .card-header {
.card-group > .card:not(:first-child) > .card-img-top,
.card-group > .card:not(:first-child) > .card-header {
border-top-right-radius: 0;
}
.card-group > .card:not(:first-child) .card-img-bottom,
.card-group > .card:not(:first-child) .card-footer {
.card-group > .card:not(:first-child) > .card-img-bottom,
.card-group > .card:not(:first-child) > .card-footer {
border-bottom-right-radius: 0;
}
}
@@ -4574,11 +4553,11 @@ textarea.form-control-lg {
--bs-accordion-btn-padding-y: 1rem;
--bs-accordion-btn-color: var(--bs-body-color);
--bs-accordion-btn-bg: var(--bs-accordion-bg);
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
--bs-accordion-btn-icon-width: 1.25rem;
--bs-accordion-btn-icon-transform: rotate(-180deg);
--bs-accordion-btn-icon-transition: transform 0.2s ease-in-out;
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
--bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
--bs-accordion-body-padding-x: 1.25rem;
--bs-accordion-body-padding-y: 1rem;
@@ -4688,16 +4667,15 @@ textarea.form-control-lg {
.accordion-flush > .accordion-item:last-child {
border-bottom: 0;
}
.accordion-flush > .accordion-item > .accordion-header .accordion-button, .accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed {
border-radius: 0;
}
.accordion-flush > .accordion-item > .accordion-collapse {
.accordion-flush > .accordion-item > .accordion-collapse,
.accordion-flush > .accordion-item > .accordion-header .accordion-button,
.accordion-flush > .accordion-item > .accordion-header .accordion-button.collapsed {
border-radius: 0;
}
[data-bs-theme=dark] .accordion-button::after {
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
}
.breadcrumb {
@@ -4801,7 +4779,7 @@ textarea.form-control-lg {
}
.page-item:not(:first-child) .page-link {
margin-right: calc(var(--bs-border-width) * -1);
margin-right: calc(-1 * var(--bs-border-width));
}
.page-item:first-child .page-link {
border-top-right-radius: var(--bs-pagination-border-radius);
@@ -4950,7 +4928,7 @@ textarea.form-control-lg {
@keyframes progress-bar-stripes {
0% {
background-position-x: 1rem;
background-position-x: var(--bs-progress-height);
}
}
.progress,
@@ -5044,22 +5022,6 @@ textarea.form-control-lg {
counter-increment: section;
}
.list-group-item-action {
width: 100%;
color: var(--bs-list-group-action-color);
text-align: inherit;
}
.list-group-item-action:hover, .list-group-item-action:focus {
z-index: 1;
color: var(--bs-list-group-action-hover-color);
text-decoration: none;
background-color: var(--bs-list-group-action-hover-bg);
}
.list-group-item-action:active {
color: var(--bs-list-group-action-active-color);
background-color: var(--bs-list-group-action-active-bg);
}
.list-group-item {
position: relative;
display: block;
@@ -5096,6 +5058,22 @@ textarea.form-control-lg {
border-top-width: var(--bs-list-group-border-width);
}
.list-group-item-action {
width: 100%;
color: var(--bs-list-group-action-color);
text-align: inherit;
}
.list-group-item-action:not(.active):hover, .list-group-item-action:not(.active):focus {
z-index: 1;
color: var(--bs-list-group-action-hover-color);
text-decoration: none;
background-color: var(--bs-list-group-action-hover-bg);
}
.list-group-item-action:not(.active):active {
color: var(--bs-list-group-action-active-color);
background-color: var(--bs-list-group-action-active-bg);
}
.list-group-horizontal {
flex-direction: row;
}
@@ -5355,19 +5333,19 @@ textarea.form-control-lg {
.btn-close {
--bs-btn-close-color: #000;
--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");
--bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");
--bs-btn-close-opacity: 0.5;
--bs-btn-close-hover-opacity: 0.75;
--bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
--bs-btn-close-focus-opacity: 1;
--bs-btn-close-disabled-opacity: 0.25;
--bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
box-sizing: content-box;
width: 1em;
height: 1em;
padding: 0.25em 0.25em;
color: var(--bs-btn-close-color);
background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat;
filter: var(--bs-btn-close-filter);
border: 0;
border-radius: 0.375rem;
opacity: var(--bs-btn-close-opacity);
@@ -5391,11 +5369,16 @@ textarea.form-control-lg {
}
.btn-close-white {
filter: var(--bs-btn-close-white-filter);
--bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%);
}
[data-bs-theme=dark] .btn-close {
filter: var(--bs-btn-close-white-filter);
:root,
[data-bs-theme=light] {
--bs-btn-close-filter: ;
}
[data-bs-theme=dark] {
--bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%);
}
.toast {
@@ -5472,7 +5455,7 @@ textarea.form-control-lg {
--bs-modal-width: 500px;
--bs-modal-padding: 1rem;
--bs-modal-margin: 0.5rem;
--bs-modal-color: ;
--bs-modal-color: var(--bs-body-color);
--bs-modal-bg: var(--bs-body-bg);
--bs-modal-border-color: var(--bs-border-color-translucent);
--bs-modal-border-width: var(--bs-border-width);
@@ -5508,8 +5491,8 @@ textarea.form-control-lg {
pointer-events: none;
}
.modal.fade .modal-dialog {
transition: transform 0.3s ease-out;
transform: translate(0, -50px);
transition: transform 0.3s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modal.fade .modal-dialog {
@@ -5584,7 +5567,10 @@ textarea.form-control-lg {
}
.modal-header .btn-close {
padding: calc(var(--bs-modal-header-padding-y) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5);
margin: calc(-0.5 * var(--bs-modal-header-padding-y)) auto calc(-0.5 * var(--bs-modal-header-padding-y)) calc(-0.5 * var(--bs-modal-header-padding-x));
margin-top: calc(-0.5 * var(--bs-modal-header-padding-y));
margin-left: calc(-0.5 * var(--bs-modal-header-padding-x));
margin-bottom: calc(-0.5 * var(--bs-modal-header-padding-y));
margin-right: auto;
}
.modal-title {
@@ -6089,6 +6075,7 @@ textarea.form-control-lg {
color: #fff;
text-align: center;
background: none;
filter: var(--bs-carousel-control-icon-filter);
border: 0;
opacity: 0.5;
transition: opacity 0.15s ease;
@@ -6127,11 +6114,11 @@ textarea.form-control-lg {
}
.carousel-control-prev-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");
}
.carousel-control-next-icon {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e");
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e");
}
.carousel-indicators {
@@ -6157,7 +6144,7 @@ textarea.form-control-lg {
margin-right: 3px;
text-indent: -999px;
cursor: pointer;
background-color: #fff;
background-color: var(--bs-carousel-indicator-active-bg);
background-clip: padding-box;
border: 0;
border-top: 10px solid transparent;
@@ -6181,36 +6168,33 @@ textarea.form-control-lg {
right: 15%;
padding-top: 1.25rem;
padding-bottom: 1.25rem;
color: #fff;
color: var(--bs-carousel-caption-color);
text-align: center;
}
.carousel-dark .carousel-control-prev-icon,
.carousel-dark .carousel-control-next-icon {
filter: invert(1) grayscale(100);
}
.carousel-dark .carousel-indicators [data-bs-target] {
background-color: #000;
}
.carousel-dark .carousel-caption {
color: #000;
.carousel-dark {
--bs-carousel-indicator-active-bg: #000;
--bs-carousel-caption-color: #000;
--bs-carousel-control-icon-filter: invert(1) grayscale(100);
}
[data-bs-theme=dark] .carousel .carousel-control-prev-icon,
[data-bs-theme=dark] .carousel .carousel-control-next-icon, [data-bs-theme=dark].carousel .carousel-control-prev-icon,
[data-bs-theme=dark].carousel .carousel-control-next-icon {
filter: invert(1) grayscale(100);
:root,
[data-bs-theme=light] {
--bs-carousel-indicator-active-bg: #fff;
--bs-carousel-caption-color: #fff;
--bs-carousel-control-icon-filter: ;
}
[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target], [data-bs-theme=dark].carousel .carousel-indicators [data-bs-target] {
background-color: #000;
}
[data-bs-theme=dark] .carousel .carousel-caption, [data-bs-theme=dark].carousel .carousel-caption {
color: #000;
[data-bs-theme=dark] {
--bs-carousel-indicator-active-bg: #000;
--bs-carousel-caption-color: #000;
--bs-carousel-control-icon-filter: invert(1) grayscale(100);
}
.spinner-grow,
.spinner-border {
display: inline-block;
flex-shrink: 0;
width: var(--bs-spinner-width);
height: var(--bs-spinner-height);
vertical-align: var(--bs-spinner-vertical-align);
@@ -6755,7 +6739,10 @@ textarea.form-control-lg {
}
.offcanvas-header .btn-close {
padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5);
margin: calc(-0.5 * var(--bs-offcanvas-padding-y)) auto calc(-0.5 * var(--bs-offcanvas-padding-y)) calc(-0.5 * var(--bs-offcanvas-padding-x));
margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y));
margin-left: calc(-0.5 * var(--bs-offcanvas-padding-x));
margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y));
margin-right: auto;
}
.offcanvas-title {
@@ -7156,6 +7143,10 @@ textarea.form-control-lg {
.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) {
position: absolute !important;
}
.visually-hidden *,
.visually-hidden-focusable:not(:focus):not(:focus-within) * {
overflow: hidden !important;
}
.stretched-link::after {
position: absolute;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
@@ -205,7 +205,7 @@
* @param {HTMLElement} element
* @return void
*
* @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
* @see https://www.harrytheo.com/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
*/
const reflow = element => {
element.offsetHeight; // eslint-disable-line no-unused-expressions
@@ -250,7 +250,7 @@
});
};
const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => {
return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue;
return typeof possibleCallback === 'function' ? possibleCallback.call(...args) : defaultValue;
};
const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
if (!waitForTransition) {
@@ -572,7 +572,7 @@
const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'));
for (const key of bsKeys) {
let pureKey = key.replace(/^bs/, '');
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length);
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1);
attributes[pureKey] = normalizeData(element.dataset[key]);
}
return attributes;
@@ -647,7 +647,7 @@
* Constants
*/
const VERSION = '5.3.3';
const VERSION = '5.3.8';
/**
* Class definition
@@ -673,6 +673,8 @@
this[propertyName] = null;
}
}
// Private
_queueCallback(callback, element, isAnimated = true) {
executeAfterTransition(callback, element, isAnimated);
}
@@ -1604,11 +1606,11 @@
this._element.style[dimension] = '';
this._queueCallback(complete, this._element, true);
}
// Private
_isShown(element = this._element) {
return element.classList.contains(CLASS_NAME_SHOW$7);
}
// Private
_configAfterMerge(config) {
config.toggle = Boolean(config.toggle); // Coerce string values
config.parent = getElement(config.parent);
@@ -2666,7 +2668,6 @@
var popperOffsets = computeOffsets({
reference: referenceClientRect,
element: popperRect,
strategy: 'absolute',
placement: placement
});
var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets));
@@ -2994,7 +2995,6 @@
state.modifiersData[name] = computeOffsets({
reference: state.rects.reference,
element: state.rects.popper,
strategy: 'absolute',
placement: state.placement
});
} // eslint-disable-next-line import/no-unused-modules
@@ -3701,7 +3701,7 @@
}
_createPopper() {
if (typeof Popper === 'undefined') {
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)');
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)');
}
let referenceElement = this._element;
if (this._config.reference === 'parent') {
@@ -3780,7 +3780,7 @@
}
return {
...defaultBsPopperConfig,
...execute(this._config.popperConfig, [defaultBsPopperConfig])
...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
};
}
_selectMenuItem({
@@ -4802,7 +4802,6 @@
*
* Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38
*/
// eslint-disable-next-line unicorn/better-regex
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i;
const allowedAttribute = (attribute, allowedAttributeList) => {
const attributeName = attribute.nodeName.toLowerCase();
@@ -4967,7 +4966,7 @@
return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg;
}
_resolvePossibleFunction(arg) {
return execute(arg, [this]);
return execute(arg, [undefined, this]);
}
_putElementInTemplate(element, templateElement) {
if (this._config.html) {
@@ -5066,7 +5065,7 @@
class Tooltip extends BaseComponent {
constructor(element, config) {
if (typeof Popper === 'undefined') {
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)');
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)');
}
super(element, config);
@@ -5112,7 +5111,6 @@
if (!this._isEnabled) {
return;
}
this._activeTrigger.click = !this._activeTrigger.click;
if (this._isShown()) {
this._leave();
return;
@@ -5300,7 +5298,7 @@
return offset;
}
_resolvePossibleFunction(arg) {
return execute(arg, [this._element]);
return execute(arg, [this._element, this._element]);
}
_getPopperConfig(attachment) {
const defaultBsPopperConfig = {
@@ -5338,7 +5336,7 @@
};
return {
...defaultBsPopperConfig,
...execute(this._config.popperConfig, [defaultBsPopperConfig])
...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
};
}
_setListeners() {
@@ -5347,6 +5345,7 @@
if (trigger === 'click') {
EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event);
context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK]);
context.toggle();
});
} else if (trigger !== TRIGGER_MANUAL) {
@@ -6212,7 +6211,6 @@
}
// Private
_maybeScheduleHide() {
if (!this._config.autohide) {
return;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
import * as Popper from '@popperjs/core';
@@ -201,7 +201,7 @@ const noop = () => {};
* @param {HTMLElement} element
* @return void
*
* @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
* @see https://www.harrytheo.com/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
*/
const reflow = element => {
element.offsetHeight; // eslint-disable-line no-unused-expressions
@@ -246,7 +246,7 @@ const defineJQueryPlugin = plugin => {
});
};
const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => {
return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue;
return typeof possibleCallback === 'function' ? possibleCallback.call(...args) : defaultValue;
};
const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
if (!waitForTransition) {
@@ -568,7 +568,7 @@ const Manipulator = {
const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'));
for (const key of bsKeys) {
let pureKey = key.replace(/^bs/, '');
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length);
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1);
attributes[pureKey] = normalizeData(element.dataset[key]);
}
return attributes;
@@ -643,7 +643,7 @@ class Config {
* Constants
*/
const VERSION = '5.3.3';
const VERSION = '5.3.8';
/**
* Class definition
@@ -669,6 +669,8 @@ class BaseComponent extends Config {
this[propertyName] = null;
}
}
// Private
_queueCallback(callback, element, isAnimated = true) {
executeAfterTransition(callback, element, isAnimated);
}
@@ -1600,11 +1602,11 @@ class Collapse extends BaseComponent {
this._element.style[dimension] = '';
this._queueCallback(complete, this._element, true);
}
// Private
_isShown(element = this._element) {
return element.classList.contains(CLASS_NAME_SHOW$7);
}
// Private
_configAfterMerge(config) {
config.toggle = Boolean(config.toggle); // Coerce string values
config.parent = getElement(config.parent);
@@ -1858,7 +1860,7 @@ class Dropdown extends BaseComponent {
}
_createPopper() {
if (typeof Popper === 'undefined') {
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)');
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)');
}
let referenceElement = this._element;
if (this._config.reference === 'parent') {
@@ -1937,7 +1939,7 @@ class Dropdown extends BaseComponent {
}
return {
...defaultBsPopperConfig,
...execute(this._config.popperConfig, [defaultBsPopperConfig])
...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
};
}
_selectMenuItem({
@@ -2959,7 +2961,6 @@ const uriAttributes = new Set(['background', 'cite', 'href', 'itemtype', 'longde
*
* Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38
*/
// eslint-disable-next-line unicorn/better-regex
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i;
const allowedAttribute = (attribute, allowedAttributeList) => {
const attributeName = attribute.nodeName.toLowerCase();
@@ -3124,7 +3125,7 @@ class TemplateFactory extends Config {
return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg;
}
_resolvePossibleFunction(arg) {
return execute(arg, [this]);
return execute(arg, [undefined, this]);
}
_putElementInTemplate(element, templateElement) {
if (this._config.html) {
@@ -3223,7 +3224,7 @@ const DefaultType$3 = {
class Tooltip extends BaseComponent {
constructor(element, config) {
if (typeof Popper === 'undefined') {
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)');
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)');
}
super(element, config);
@@ -3269,7 +3270,6 @@ class Tooltip extends BaseComponent {
if (!this._isEnabled) {
return;
}
this._activeTrigger.click = !this._activeTrigger.click;
if (this._isShown()) {
this._leave();
return;
@@ -3457,7 +3457,7 @@ class Tooltip extends BaseComponent {
return offset;
}
_resolvePossibleFunction(arg) {
return execute(arg, [this._element]);
return execute(arg, [this._element, this._element]);
}
_getPopperConfig(attachment) {
const defaultBsPopperConfig = {
@@ -3495,7 +3495,7 @@ class Tooltip extends BaseComponent {
};
return {
...defaultBsPopperConfig,
...execute(this._config.popperConfig, [defaultBsPopperConfig])
...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
};
}
_setListeners() {
@@ -3504,6 +3504,7 @@ class Tooltip extends BaseComponent {
if (trigger === 'click') {
EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event);
context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK]);
context.toggle();
});
} else if (trigger !== TRIGGER_MANUAL) {
@@ -4369,7 +4370,6 @@ class Toast extends BaseComponent {
}
// Private
_maybeScheduleHide() {
if (!this._config.autohide) {
return;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -1,6 +1,6 @@
/*!
* Bootstrap v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
@@ -224,7 +224,7 @@
* @param {HTMLElement} element
* @return void
*
* @see https://www.charistheo.io/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
* @see https://www.harrytheo.com/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
*/
const reflow = element => {
element.offsetHeight; // eslint-disable-line no-unused-expressions
@@ -269,7 +269,7 @@
});
};
const execute = (possibleCallback, args = [], defaultValue = possibleCallback) => {
return typeof possibleCallback === 'function' ? possibleCallback(...args) : defaultValue;
return typeof possibleCallback === 'function' ? possibleCallback.call(...args) : defaultValue;
};
const executeAfterTransition = (callback, transitionElement, waitForTransition = true) => {
if (!waitForTransition) {
@@ -591,7 +591,7 @@
const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig'));
for (const key of bsKeys) {
let pureKey = key.replace(/^bs/, '');
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length);
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1);
attributes[pureKey] = normalizeData(element.dataset[key]);
}
return attributes;
@@ -666,7 +666,7 @@
* Constants
*/
const VERSION = '5.3.3';
const VERSION = '5.3.8';
/**
* Class definition
@@ -692,6 +692,8 @@
this[propertyName] = null;
}
}
// Private
_queueCallback(callback, element, isAnimated = true) {
executeAfterTransition(callback, element, isAnimated);
}
@@ -1623,11 +1625,11 @@
this._element.style[dimension] = '';
this._queueCallback(complete, this._element, true);
}
// Private
_isShown(element = this._element) {
return element.classList.contains(CLASS_NAME_SHOW$7);
}
// Private
_configAfterMerge(config) {
config.toggle = Boolean(config.toggle); // Coerce string values
config.parent = getElement(config.parent);
@@ -1881,7 +1883,7 @@
}
_createPopper() {
if (typeof Popper__namespace === 'undefined') {
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)');
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)');
}
let referenceElement = this._element;
if (this._config.reference === 'parent') {
@@ -1960,7 +1962,7 @@
}
return {
...defaultBsPopperConfig,
...execute(this._config.popperConfig, [defaultBsPopperConfig])
...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
};
}
_selectMenuItem({
@@ -2982,7 +2984,6 @@
*
* Shout-out to Angular https://github.com/angular/angular/blob/15.2.8/packages/core/src/sanitization/url_sanitizer.ts#L38
*/
// eslint-disable-next-line unicorn/better-regex
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i;
const allowedAttribute = (attribute, allowedAttributeList) => {
const attributeName = attribute.nodeName.toLowerCase();
@@ -3147,7 +3148,7 @@
return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg;
}
_resolvePossibleFunction(arg) {
return execute(arg, [this]);
return execute(arg, [undefined, this]);
}
_putElementInTemplate(element, templateElement) {
if (this._config.html) {
@@ -3246,7 +3247,7 @@
class Tooltip extends BaseComponent {
constructor(element, config) {
if (typeof Popper__namespace === 'undefined') {
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org)');
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)');
}
super(element, config);
@@ -3292,7 +3293,6 @@
if (!this._isEnabled) {
return;
}
this._activeTrigger.click = !this._activeTrigger.click;
if (this._isShown()) {
this._leave();
return;
@@ -3480,7 +3480,7 @@
return offset;
}
_resolvePossibleFunction(arg) {
return execute(arg, [this._element]);
return execute(arg, [this._element, this._element]);
}
_getPopperConfig(attachment) {
const defaultBsPopperConfig = {
@@ -3518,7 +3518,7 @@
};
return {
...defaultBsPopperConfig,
...execute(this._config.popperConfig, [defaultBsPopperConfig])
...execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
};
}
_setListeners() {
@@ -3527,6 +3527,7 @@
if (trigger === 'click') {
EventHandler.on(this._element, this.constructor.eventName(EVENT_CLICK$1), this._config.selector, event => {
const context = this._initializeOnDelegatedTarget(event);
context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK]);
context.toggle();
});
} else if (trigger !== TRIGGER_MANUAL) {
@@ -4392,7 +4393,6 @@
}
// Private
_maybeScheduleHide() {
if (!this._config.autohide) {
return;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long