This commit is contained in:
6d486f49
2026-08-17 00:45:08 -04:00
parent 3a2f112a73
commit 1d6207fbfe
154 changed files with 126007 additions and 47385 deletions
+9 -3
View File
@@ -10,19 +10,25 @@ public class AppDbContext : DbContext
{
}
public DbSet<User> Users { get; set; }
public DbSet<CardNote> CardNotes { get; set; }
public DbSet<UserDeck> UserDecks { get; set; }
public DbSet<PasskeyCredential> PasskeyCredentials { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<CardNote>().HasKey(n => n.CardName);
modelBuilder.Entity<User>(b => { b.HasKey(u => u.Username); });
modelBuilder.Entity<CardNote>(b => { b.HasKey(n => new { n.Username, n.CardName }); });
modelBuilder.Entity<UserDeck>(b =>
{
b.HasKey(d => d.Id);
b.Property(d => d.Cards).HasColumnType("jsonb");
b.Property(d => d.Divers).HasColumnType("jsonb");
if (Database.IsNpgsql())
{
b.Property(d => d.Cards).HasColumnType("jsonb");
b.Property(d => d.Divers).HasColumnType("jsonb");
}
});
}
}
+22 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Npgsql;
namespace Cloud;
@@ -7,8 +8,28 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var envPassword = Environment.GetEnvironmentVariable("PostgreSQL_Password")
?? (OperatingSystem.IsWindows()
? Environment.GetEnvironmentVariable("PostgreSQL_Password",
EnvironmentVariableTarget.User)
: null)
?? (OperatingSystem.IsWindows()
? Environment.GetEnvironmentVariable("PostgreSQL_Password",
EnvironmentVariableTarget.Machine)
: null)
?? Environment.GetEnvironmentVariable("POSTGRESQL_PASSWORD")
?? Environment.GetEnvironmentVariable("POSTGRES_PASSWORD");
var csb = new NpgsqlConnectionStringBuilder
{
Host = "localhost",
Database = "chrono",
Username = "postgres",
Password = envPassword
};
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql("Host=localhost;Database=chrono;Username=postgres;Password=postgres")
.UseNpgsql(csb.ConnectionString)
.Options;
return new AppDbContext(options);
}
@@ -22,7 +22,8 @@
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-chrono-secondary" @onclick="RefreshHealthAsync" title="Refresh Health Check">
<button class="btn btn-sm btn-chrono-secondary" @onclick="RefreshHealthAsync"
title="Refresh Health Check">
<i class="bi bi-arrow-clockwise @(isChecking ? "spin" : "")"></i> Refresh
</button>
<a href="test-suite" class="btn btn-sm btn-chrono-primary">
@@ -94,4 +95,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -21,20 +21,20 @@ main {
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
@@ -90,9 +90,9 @@ main {
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -9,11 +9,14 @@
<div class="p-2 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="text-secondary small fw-semibold">PROVIDER</span>
<span class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")" style="font-size: 0.72rem;">
<span
class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")"
style="font-size: 0.72rem;">
@ConfigService.ProviderType
</span>
</div>
<div class="small text-truncate text-light" style="font-size: 0.78rem;" title="@ConfigService.GetActiveProviderDescription()">
<div class="small text-truncate text-light" style="font-size: 0.78rem;"
title="@ConfigService.GetActiveProviderDescription()">
@(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "PostgreSQL Server" : "In-Memory Test DB")
</div>
</div>
@@ -35,6 +38,11 @@
<span>User Decks</span>
</NavLink>
<NavLink class="nav-link" href="users">
<i class="bi bi-people-fill"></i>
<span>Users</span>
</NavLink>
<NavLink class="nav-link" href="passkeys">
<i class="bi bi-key-fill"></i>
<span>Passkeys</span>
@@ -59,6 +67,7 @@
</div>
@code {
protected override void OnInitialized()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
@@ -73,4 +82,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -17,7 +17,7 @@
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
background-color: rgba(0, 0, 0, 0.4);
}
.navbar-brand {
@@ -51,33 +51,33 @@
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
background-color: rgba(255, 255, 255, 0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
@@ -31,12 +31,11 @@
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
&[open] {
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
@@ -96,13 +95,13 @@
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
@@ -110,17 +109,17 @@
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
@@ -31,8 +31,9 @@
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by card name or note content..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterNotes" />
<input type="text" class="form-control form-control-chrono"
placeholder="Search by card name or note content..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterNotes"/>
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
@@ -72,37 +73,44 @@
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 25%;">Card Name (Primary Key)</th>
<th>Note Content</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
<tr>
<th style="width: 20%;">User</th>
<th style="width: 25%;">Card Name</th>
<th>Note Content</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var note in notes)
{
<tr>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-card-heading text-primary"></i>
@note.CardName
</div>
</td>
<td>
<div class="text-light text-break">@note.Note</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(note)" title="Edit Note">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(note)" title="Delete Note">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
@foreach (var note in notes)
{
<tr>
<td>
<span
class="badge bg-secondary font-monospace">@(string.IsNullOrEmpty(note.Username) ? "global" : note.Username)</span>
</td>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-card-heading text-primary"></i>
@note.CardName
</div>
</td>
<td>
<div class="text-light text-break">@note.Note</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(note)"
title="Edit Note">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(note)"
title="Delete Note">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -122,24 +130,44 @@
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">USERNAME</label>
@if (isEditing)
{
<input type="text" class="form-control form-control-chrono" value="@currentNote.Username"
disabled/>
}
else
{
<input type="text" class="form-control form-control-chrono"
placeholder="Enter username (e.g. player1)..." @bind="currentNote.Username"/>
<div class="form-text text-secondary">The username this note is associated with.</div>
}
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">CARD NAME</label>
@if (isEditing)
{
<input type="text" class="form-control form-control-chrono" value="@currentNote.CardName" disabled />
<div class="form-text text-secondary">Card name acts as the primary key and cannot be altered.</div>
<input type="text" class="form-control form-control-chrono" value="@currentNote.CardName"
disabled/>
<div class="form-text text-secondary">Card name acts as part of the primary key and cannot be
altered.
</div>
}
else
{
<input type="text" class="form-control form-control-chrono" list="cardSuggestions"
placeholder="Enter or select card name..." @bind="currentNote.CardName" />
placeholder="Enter or select card name..." @bind="currentNote.CardName"/>
<datalist id="cardSuggestions">
@foreach (var c in availableCardNames.Take(50))
{
<option value="@c" />
<option value="@c"/>
}
</datalist>
<div class="form-text text-secondary">Select an existing card from the database or enter a custom name.</div>
<div class="form-text text-secondary">Select an existing card from the database or enter a
custom name.
</div>
}
</div>
@@ -170,17 +198,20 @@
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Note
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete the note for:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3">
@noteToDelete.CardName
</div>
<p class="text-secondary small mb-0">This action will remove the record from the database permanently.</p>
<p class="text-secondary small mb-0">This action will remove the record from the database
permanently.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Permanently")
</button>
@@ -194,7 +225,7 @@
private List<string> availableCardNames = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private bool isSaving;
private string? alertMessage;
private bool alertIsSuccess;
@@ -208,7 +239,7 @@
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
try
{
availableCardNames = CardDatabase.Cards
@@ -272,6 +303,7 @@
isEditing = true;
currentNote = new CardNote
{
Username = note.Username,
CardName = note.CardName,
Note = note.Note
};
@@ -317,7 +349,7 @@
if (noteToDelete == null) return;
isSaving = true;
var result = await DbService.DeleteCardNoteAsync(noteToDelete.CardName);
var result = await DbService.DeleteCardNoteAsync(noteToDelete.CardName, noteToDelete.Username);
isSaving = false;
alertIsSuccess = result.Success;
@@ -340,4 +372,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -10,7 +10,7 @@
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private int currentCount;
private void IncrementCount()
{
@@ -6,7 +6,8 @@
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Settings & Migrations</h2>
<p class="text-secondary mb-0">Configure database providers, update connection strings, inspect EF Core migrations, and run maintenance tasks.</p>
<p class="text-secondary mb-0">Configure database providers, update connection strings, inspect EF Core
migrations, and run maintenance tasks.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync">
@@ -38,12 +39,14 @@
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">ACTIVE PROVIDER</label>
<div class="d-flex gap-2">
<button class="btn flex-fill @(selectedProvider == DatabaseProviderType.PostgreSQL ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.PostgreSQL)">
<button
class="btn flex-fill @(selectedProvider == DatabaseProviderType.PostgreSQL ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.PostgreSQL)">
<i class="bi bi-database me-1"></i> PostgreSQL (Cloud DB)
</button>
<button class="btn flex-fill @(selectedProvider == DatabaseProviderType.InMemory ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.InMemory)">
<button
class="btn flex-fill @(selectedProvider == DatabaseProviderType.InMemory ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.InMemory)">
<i class="bi bi-cpu me-1"></i> In-Memory Test DB
</button>
</div>
@@ -80,7 +83,7 @@
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">IN-MEMORY DATABASE INSTANCE NAME</label>
<input type="text" class="form-control form-control-chrono font-monospace"
@bind="inMemoryNameInput" />
@bind="inMemoryNameInput"/>
<div class="form-text text-secondary">
In-memory EF Core provider creates an isolated database in RAM for instant testing.
</div>
@@ -112,29 +115,34 @@
@if (health != null)
{
<div class="d-flex flex-column gap-2 mb-4">
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">Connection Status</span>
<span class="fw-bold @(health.IsConnected ? "text-success" : "text-danger")">
@(health.IsConnected ? "Connected" : "Disconnected")
</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">Query Latency</span>
<span class="text-white font-monospace">@(health.ResponseTimeMs) ms</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">CardNotes Table Rows</span>
<span class="text-primary fw-bold">@health.CardNotesCount</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">UserDecks Table Rows</span>
<span class="text-success fw-bold">@health.UserDecksCount</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">PasskeyCredentials Table Rows</span>
<span class="text-purple fw-bold">@health.PasskeysCount</span>
</div>
@@ -162,7 +170,8 @@
@if (migrationReport?.SupportsMigrations == true && migrationReport.PendingMigrations.Any())
{
<button class="btn btn-chrono-primary btn-sm" @onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<i class="bi bi-arrow-up-circle-fill me-1"></i> Apply Pending Migrations (@migrationReport.PendingMigrations.Count)
<i class="bi bi-arrow-up-circle-fill me-1"></i> Apply Pending Migrations
(@migrationReport.PendingMigrations.Count)
</button>
}
</div>
@@ -170,14 +179,16 @@
@if (migrationReport == null)
{
<div class="text-center py-4 text-secondary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> Loading migration report...
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading migration report...
</div>
}
else if (!migrationReport.SupportsMigrations)
{
<div class="p-3 bg-dark rounded border border-secondary text-secondary small">
<i class="bi bi-info-circle me-1"></i>
Migrations are managed via relational schema (PostgreSQL). The active in-memory provider does not use relational migration tables.
Migrations are managed via relational schema (PostgreSQL). The active in-memory provider does not use
relational migration tables.
</div>
}
else
@@ -200,7 +211,8 @@
<div class="d-flex flex-column gap-2" style="max-height: 250px; overflow-y: auto;">
@foreach (var m in migrationReport.AppliedMigrations)
{
<div class="p-2 rounded bg-dark border border-success border-opacity-50 d-flex align-items-center justify-content-between">
<div
class="p-2 rounded bg-dark border border-success border-opacity-50 d-flex align-items-center justify-content-between">
<span class="text-white small font-monospace">@m</span>
<span class="badge bg-success"><i class="bi bi-check"></i> Applied</span>
</div>
@@ -218,7 +230,8 @@
@if (!migrationReport.PendingMigrations.Any())
{
<div class="p-3 bg-dark rounded border border-success border-opacity-25 text-success small">
<i class="bi bi-shield-check me-1"></i> All migrations are applied. Schema is in sync with AppDbContext.
<i class="bi bi-shield-check me-1"></i> All migrations are applied. Schema is in sync with
AppDbContext.
</div>
}
else
@@ -226,7 +239,8 @@
<div class="d-flex flex-column gap-2" style="max-height: 250px; overflow-y: auto;">
@foreach (var m in migrationReport.PendingMigrations)
{
<div class="p-2 rounded bg-dark border border-warning border-opacity-50 d-flex align-items-center justify-content-between">
<div
class="p-2 rounded bg-dark border border-warning border-opacity-50 d-flex align-items-center justify-content-between">
<span class="text-warning small font-monospace">@m</span>
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Pending</span>
</div>
@@ -357,4 +371,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -15,12 +15,14 @@
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
Swapping to <strong>Development</strong> environment will display more detailed information about the error that
occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong>
environment variable to <strong>Development</strong>
and restarting the app.
</p>
@@ -30,7 +32,9 @@
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
protected override void OnInitialized()
{
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}
+52 -20
View File
@@ -6,7 +6,8 @@
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Test Dashboard</h2>
<p class="text-secondary mb-0">Monitor, manage, and validate all database features referenced in Chrono Cloud</p>
<p class="text-secondary mb-0">Monitor, manage, and validate all database features referenced in Chrono
Cloud</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync" disabled="@isLoading">
@@ -78,49 +79,66 @@
<!-- Metrics Row -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="col-md">
<a href="card-notes" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-blue">
<i class="bi bi-card-text"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.CardNotesCount.ToString() : "-")</div>
<div
class="metric-value">@(health?.IsConnected == true ? health.CardNotesCount.ToString() : "-")</div>
<div class="metric-label">Card Notes</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="col-md">
<a href="users" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
<i class="bi bi-people-fill"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.UsersCount.ToString() : "-")</div>
<div class="metric-label">Users</div>
</div>
</div>
</a>
</div>
<div class="col-md">
<a href="user-decks" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-green">
<i class="bi bi-collection-play"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.UserDecksCount.ToString() : "-")</div>
<div
class="metric-value">@(health?.IsConnected == true ? health.UserDecksCount.ToString() : "-")</div>
<div class="metric-label">User Decks</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="col-md">
<a href="passkeys" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-purple">
<i class="bi bi-key-fill"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.PasskeysCount.ToString() : "-")</div>
<div
class="metric-value">@(health?.IsConnected == true ? health.PasskeysCount.ToString() : "-")</div>
<div class="metric-label">Passkeys</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="col-md">
<a href="diagnostics" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
@@ -128,11 +146,11 @@
</div>
<div>
<div class="metric-value">
@(health?.ProviderType == DatabaseProviderType.PostgreSQL
? $"{health.AppliedMigrationsCount} / {health.AppliedMigrationsCount + health.PendingMigrationsCount}"
@(health?.ProviderType == DatabaseProviderType.PostgreSQL
? $"{health.AppliedMigrationsCount} / {health.AppliedMigrationsCount + health.PendingMigrationsCount}"
: "N/A")
</div>
<div class="metric-label">Applied Migrations</div>
<div class="metric-label">Migrations</div>
</div>
</div>
</a>
@@ -158,7 +176,8 @@
<a href="card-notes" class="btn btn-sm btn-chrono-secondary">Manage Notes</a>
</div>
<p class="text-secondary small mb-0">
Tracks developer and user strategic card notes with primary key indexing on <code>CardName</code>. Supports live search, adding, updating, and deletion.
Tracks developer and user strategic card notes with primary key indexing on
<code>CardName</code>. Supports live search, adding, updating, and deletion.
</p>
</div>
@@ -168,7 +187,8 @@
<a href="user-decks" class="btn btn-sm btn-chrono-secondary">Manage Decks</a>
</div>
<p class="text-secondary small mb-0">
Stores full player decks with native PostgreSQL <code>jsonb</code> mapping for <code>Cards</code> list (with counts) and <code>Divers</code> list.
Stores full player decks with native PostgreSQL <code>jsonb</code> mapping for
<code>Cards</code> list (with counts) and <code>Divers</code> list.
</p>
</div>
@@ -178,7 +198,8 @@
<a href="passkeys" class="btn btn-sm btn-chrono-secondary">Manage Passkeys</a>
</div>
<p class="text-secondary small mb-0">
Stores WebAuthn / FIDO2 binary credentials (raw <code>byte[]</code> for CredentialId, PublicKey, UserHandle) with counter verification.
Stores WebAuthn / FIDO2 binary credentials (raw <code>byte[]</code> for CredentialId, PublicKey,
UserHandle) with counter verification.
</p>
</div>
</div>
@@ -195,37 +216,47 @@
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-chrono-primary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SeedDataAsync" disabled="@isActionBusy">
<button class="btn btn-chrono-primary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="SeedDataAsync" disabled="@isActionBusy">
<span><i class="bi bi-box-arrow-in-down me-2"></i> Seed Realistic Sample Data</span>
<i class="bi bi-arrow-right"></i>
</button>
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<button
class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<span><i class="bi bi-database-check me-2"></i> Apply Pending Migrations</span>
<i class="bi bi-arrow-right"></i>
</button>
@if (ConfigService.ProviderType == DatabaseProviderType.PostgreSQL)
{
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SwitchToInMemoryAsync">
<button
class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="SwitchToInMemoryAsync">
<span><i class="bi bi-cpu me-2"></i> Switch to In-Memory DB</span>
<span class="badge bg-purple">Instant</span>
</button>
}
else
{
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SwitchToPostgresAsync">
<button
class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="SwitchToPostgresAsync">
<span><i class="bi bi-database me-2"></i> Switch to PostgreSQL</span>
<span class="badge bg-primary">Live</span>
</button>
}
<a href="test-suite" class="btn btn-chrono-outline-primary py-2 text-start d-flex align-items-center justify-content-between">
<a href="test-suite"
class="btn btn-chrono-outline-primary py-2 text-start d-flex align-items-center justify-content-between">
<span><i class="bi bi-clipboard2-check me-2"></i> Open Test Suite Runner</span>
<i class="bi bi-arrow-right"></i>
</a>
<button class="btn btn-chrono-danger py-2 text-start d-flex align-items-center justify-content-between mt-2" @onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<button
class="btn btn-chrono-danger py-2 text-start d-flex align-items-center justify-content-between mt-2"
@onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<span><i class="bi bi-trash3-fill me-2"></i> Wipe & Reset Database</span>
<i class="bi bi-exclamation-triangle"></i>
</button>
@@ -310,4 +341,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -1,13 +1,14 @@
@page "/passkeys"
@implements IDisposable
@using System.Security.Cryptography
@implements IDisposable
<PageTitle>Passkeys Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Passkey Credentials Database (`PasskeyCredential`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete WebAuthn / FIDO2 security credentials with raw binary byte arrays.</p>
<p class="text-secondary mb-0">Browse, add, edit, and delete WebAuthn / FIDO2 security credentials with raw
binary byte arrays.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadPasskeysAsync">
@@ -32,8 +33,9 @@
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by AaGuid, ID, or SignCount..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterPasskeys" />
<input type="text" class="form-control form-control-chrono"
placeholder="Search by AaGuid, ID, or SignCount..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterPasskeys"/>
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
@@ -73,53 +75,61 @@
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 220px;">AAGUID</th>
<th style="width: 120px;">Sign Count</th>
<th>Credential ID (Byte[])</th>
<th>Public Key (Byte[])</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 220px;">AAGUID</th>
<th style="width: 120px;">Sign Count</th>
<th>Credential ID (Byte[])</th>
<th>Public Key (Byte[])</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var p in passkeys)
{
<tr>
<td>
<span class="badge bg-dark border border-secondary text-white">#@p.Id</span>
</td>
<td>
<div class="text-white small font-monospace">@(string.IsNullOrEmpty(p.AaGuid) ? "(None)" : p.AaGuid)</div>
</td>
<td>
<span class="badge bg-purple">@p.SignCount</span>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.CredentialId)">
@Convert.ToHexString(p.CredentialId)[..Math.Min(16, p.CredentialId.Length * 2)]... (@p.CredentialId.Length bytes)
</div>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.PublicKey)">
@Convert.ToHexString(p.PublicKey)[..Math.Min(16, p.PublicKey.Length * 2)]... (@p.PublicKey.Length bytes)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(p)" title="View Binary Details">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(p)" title="Edit Passkey">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(p)" title="Delete Passkey">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
@foreach (var p in passkeys)
{
<tr>
<td>
<span class="badge bg-dark border border-secondary text-white">#@p.Id</span>
</td>
<td>
<div
class="text-white small font-monospace">@(string.IsNullOrEmpty(p.AaGuid) ? "(None)" : p.AaGuid)</div>
</td>
<td>
<span class="badge bg-purple">@p.SignCount</span>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;"
title="@Convert.ToHexString(p.CredentialId)">
@Convert.ToHexString(p.CredentialId)[..Math.Min(16, p.CredentialId.Length * 2)]...
(@p.CredentialId.Length bytes)
</div>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;"
title="@Convert.ToHexString(p.PublicKey)">
@Convert.ToHexString(p.PublicKey)[..Math.Min(16, p.PublicKey.Length * 2)]...
(@p.PublicKey.Length bytes)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(p)"
title="View Binary Details">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(p)"
title="Edit Passkey">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(p)"
title="Delete Passkey">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -151,13 +161,14 @@
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label text-secondary small fw-bold">AAGUID</label>
<input type="text" class="form-control form-control-chrono font-monospace" placeholder="e.g. 00000000-0000-0000-0000-000000000000"
@bind="currentPasskey.AaGuid" />
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. 00000000-0000-0000-0000-000000000000"
@bind="currentPasskey.AaGuid"/>
</div>
<div class="col-md-4">
<label class="form-label text-secondary small fw-bold">SIGN COUNT</label>
<input type="number" class="form-control form-control-chrono" min="0"
@bind="currentPasskey.SignCount" />
@bind="currentPasskey.SignCount"/>
</div>
</div>
@@ -188,7 +199,7 @@
</div>
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. DEADBEAF01020304"
@bind="userHandleHex" />
@bind="userHandleHex"/>
</div>
</div>
<div class="modal-footer-custom">
@@ -254,7 +265,8 @@
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close
</button>
</div>
</div>
</div>
@@ -270,17 +282,20 @@
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Passkey
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete passkey credential:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3 font-monospace">
Passkey #@passkeyToDelete.Id (AAGUID: @(passkeyToDelete.AaGuid ?? "N/A"))
</div>
<p class="text-secondary small mb-0">This will permanently delete the binary security credential from the database.</p>
<p class="text-secondary small mb-0">This will permanently delete the binary security credential from
the database.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Passkey")
</button>
@@ -293,18 +308,20 @@
private List<PasskeyCredential> passkeys = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private bool isSaving;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private PasskeyCredential currentPasskey = new()
{
CredentialId = [],
PublicKey = [],
UserHandle = []
};
private string credIdHex = "";
private string pubKeyHex = "";
private string userHandleHex = "";
@@ -429,12 +446,14 @@
alertMessage = "Invalid Credential ID hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanPub) || cleanPub.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid Public Key hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanUser) || cleanUser.Length % 2 != 0)
{
alertIsSuccess = false;
@@ -506,4 +525,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -6,7 +6,8 @@
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Feature Test Suite</h2>
<p class="text-secondary mb-0">Automated end-to-end verification of all database features, constraints, and JSONB mappings referenced in Chrono Cloud.</p>
<p class="text-secondary mb-0">Automated end-to-end verification of all database features, constraints, and
JSONB mappings referenced in Chrono Cloud.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="ClearResults" disabled="@isRunning">
@@ -47,7 +48,8 @@
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box @(testResults.Any(t => t.Status == TestStatus.Failed) ? "metric-icon-red" : "metric-icon-purple")">
<div
class="metric-icon-box @(testResults.Any(t => t.Status == TestStatus.Failed) ? "metric-icon-red" : "metric-icon-purple")">
<i class="bi @(testResults.Any(t => t.Status == TestStatus.Failed) ? "bi-x-circle-fill text-danger" : "bi-shield-check")"></i>
</div>
<div>
@@ -94,7 +96,8 @@
</h5>
<div class="d-flex align-items-center gap-2">
<span class="text-secondary small">Target Provider:</span>
<span class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")">
<span
class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")">
@ConfigService.ProviderType
</span>
</div>
@@ -121,7 +124,8 @@
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary font-monospace">@test.Id</span>
<span class="fw-bold text-white fs-6">@test.Name</span>
<span class="badge bg-dark border border-secondary text-secondary small">@test.Category</span>
<span
class="badge bg-dark border border-secondary text-secondary small">@test.Category</span>
</div>
<div class="d-flex align-items-center gap-3">
@@ -187,7 +191,7 @@
@code {
private List<DatabaseTestCaseResult> testResults = [];
private HashSet<string> expandedTestLogs = [];
private readonly HashSet<string> expandedTestLogs = [];
private bool isRunning;
private string currentProgressStatus = "";
@@ -240,24 +244,31 @@
expandedTestLogs.Add(testId);
}
private static string GetBorderClass(TestStatus status) => status switch
private static string GetBorderClass(TestStatus status)
{
TestStatus.Passed => "border-success border-opacity-50",
TestStatus.Failed => "border-danger border-opacity-75",
TestStatus.Running => "border-primary border-opacity-50",
_ => "border-secondary border-opacity-25"
};
return status switch
{
TestStatus.Passed => "border-success border-opacity-50",
TestStatus.Failed => "border-danger border-opacity-75",
TestStatus.Running => "border-primary border-opacity-50",
_ => "border-secondary border-opacity-25"
};
}
private static string GetBadgeClass(TestStatus status) => status switch
private static string GetBadgeClass(TestStatus status)
{
TestStatus.Passed => "bg-success",
TestStatus.Failed => "bg-danger",
TestStatus.Running => "bg-primary",
_ => "bg-secondary"
};
return status switch
{
TestStatus.Passed => "bg-success",
TestStatus.Failed => "bg-danger",
TestStatus.Running => "bg-primary",
_ => "bg-secondary"
};
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
@page "/users"
@implements IDisposable
<PageTitle>Users Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Users Database (`User`)</h2>
<p class="text-secondary mb-0">Browse, add, edit credentials, and delete user accounts.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadUsersAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add New User
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="chrono-card">
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by username..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterUsers"/>
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @users.Count user(s)
</div>
</div>
@if (isLoading)
{
<div class="text-center py-5 text-secondary">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div>Loading users from database...</div>
</div>
}
else if (!users.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-people fs-1 d-block mb-2"></i>
<h5>No Users Found</h5>
<p class="small">There are currently no users in the database.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add First User
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedUsersAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Users
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 30%;">Username (Primary Key)</th>
<th style="width: 35%;">Password Hash / Credential</th>
<th style="width: 20%;">Created At</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in users)
{
<tr>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-person-circle text-info"></i>
@user.Username
</div>
</td>
<td>
<div class="text-secondary font-monospace">••••••••</div>
</td>
<td>
<div class="text-secondary small">@user.CreatedAt.ToString("g")</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(user)"
title="Edit Password">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(user)"
title="Delete User">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (showEditModal)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content chrono-modal">
<div class="modal-header border-secondary border-opacity-25">
<h5 class="modal-title text-white">
<i class="bi @(isNewUser ? "bi-person-plus-fill" : "bi-pencil-square") me-2 text-primary"></i>
@(isNewUser ? "Add New User" : $"Edit User: {currentUser.Username}")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">USERNAME</label>
<input type="text" class="form-control form-control-chrono"
@bind="currentUser.Username" disabled="@(!isNewUser)"
placeholder="e.g. player1"/>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">PASSWORD</label>
<input type="password" class="form-control form-control-chrono"
@bind="currentUser.Password"
placeholder="Enter password..."/>
</div>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveUserAsync" disabled="@isSaving">
<i class="bi bi-save me-1"></i> @(isSaving ? "Saving..." : "Save User")
</button>
</div>
</div>
</div>
</div>
}
@if (showDeleteModal && userToDelete != null)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content chrono-modal">
<div class="modal-header border-secondary border-opacity-25">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i> Confirm Delete
</h5>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body">
<p class="text-light mb-0">
Are you sure you want to delete user <strong class="text-white">@userToDelete.Username</strong>?
</p>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">
Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync"
disabled="@isSaving">
<i class="bi bi-trash3-fill me-1"></i> @(isSaving ? "Deleting..." : "Delete")
</button>
</div>
</div>
</div>
</div>
}
@code {
private List<User> users = [];
private bool isLoading = true;
private bool isSaving;
private string searchTerm = string.Empty;
private bool showEditModal;
private bool isNewUser;
private User currentUser = new();
private bool showDeleteModal;
private User? userToDelete;
private string? alertMessage;
private bool alertIsSuccess;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await LoadUsersAsync();
}
private async void HandleConfigChanged()
{
await InvokeAsync(LoadUsersAsync);
}
private async Task LoadUsersAsync()
{
isLoading = true;
alertMessage = null;
try
{
users = await DbService.GetUsersAsync(searchTerm);
}
catch (Exception ex)
{
alertMessage = $"Error loading users: {ex.Message}";
alertIsSuccess = false;
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task FilterUsers()
{
users = await DbService.GetUsersAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = string.Empty;
await LoadUsersAsync();
}
private void OpenAddModal()
{
currentUser = new User();
isNewUser = true;
showEditModal = true;
}
private void OpenEditModal(User user)
{
currentUser = new User
{
Username = user.Username,
Password = user.Password,
CreatedAt = user.CreatedAt
};
isNewUser = false;
showEditModal = true;
}
private void CloseModal()
{
showEditModal = false;
}
private async Task SaveUserAsync()
{
if (string.IsNullOrWhiteSpace(currentUser.Username) || string.IsNullOrWhiteSpace(currentUser.Password))
{
alertMessage = "Username and password cannot be empty.";
alertIsSuccess = false;
return;
}
isSaving = true;
try
{
var result = await DbService.SaveUserAsync(currentUser, isNewUser);
alertMessage = result.Message;
alertIsSuccess = result.Success;
if (result.Success)
{
showEditModal = false;
await LoadUsersAsync();
}
}
finally
{
isSaving = false;
}
}
private void OpenDeleteModal(User user)
{
userToDelete = user;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (userToDelete == null) return;
isSaving = true;
try
{
var result = await DbService.DeleteUserAsync(userToDelete.Username);
alertMessage = result.Message;
alertIsSuccess = result.Success;
showDeleteModal = false;
userToDelete = null;
await LoadUsersAsync();
}
finally
{
isSaving = false;
}
}
private async Task SeedUsersAsync()
{
isLoading = true;
try
{
var res = await DbService.SeedSampleDataAsync();
alertMessage = res.Message;
alertIsSuccess = res.Success;
await LoadUsersAsync();
}
finally
{
isLoading = false;
}
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
@using DatabaseTest.Components.Pages
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
+5 -5
View File
@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
<ProjectReference Include="..\Model\Model.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\Generated\Cards.g.cs" Link="Generated\Cards.g.cs" />
<Compile Include="..\Shared\Generated\Decks.g.cs" Link="Generated\Decks.g.cs" />
<Compile Include="..\Shared\Generated\Cards.g.cs" Link="Generated\Cards.g.cs"/>
<Compile Include="..\Shared\Generated\Decks.g.cs" Link="Generated\Decks.g.cs"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2"/>
</ItemGroup>
<PropertyGroup>
+16 -7
View File
@@ -10,6 +10,7 @@ public class DatabaseHealthStatus
public int CardNotesCount { get; set; }
public int UserDecksCount { get; set; }
public int PasskeysCount { get; set; }
public int UsersCount { get; set; }
public int PendingMigrationsCount { get; set; }
public int AppliedMigrationsCount { get; set; }
public string? ErrorMessage { get; set; }
@@ -22,13 +23,20 @@ public class OperationResult
public string Message { get; set; } = string.Empty;
public string? ErrorDetails { get; set; }
public static OperationResult Ok(string message) => new() { Success = true, Message = message };
public static OperationResult Fail(string message, string? errorDetails = null) => new()
public static OperationResult Ok(string message)
{
Success = false,
Message = message,
ErrorDetails = errorDetails
};
return new OperationResult { Success = true, Message = message };
}
public static OperationResult Fail(string message, string? errorDetails = null)
{
return new OperationResult
{
Success = false,
Message = message,
ErrorDetails = errorDetails
};
}
}
public class SeedResult
@@ -37,6 +45,7 @@ public class SeedResult
public int CardNotesSeeded { get; set; }
public int UserDecksSeeded { get; set; }
public int PasskeysSeeded { get; set; }
public int UsersSeeded { get; set; }
public string Message { get; set; } = string.Empty;
}
@@ -74,4 +83,4 @@ public class DatabaseTestCaseResult
{
Logs.Add($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}");
}
}
}
@@ -4,4 +4,4 @@ public enum DatabaseProviderType
{
PostgreSQL,
InMemory
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseExceptionHandler("/Error", true);
app.UseHsts();
}
@@ -28,4 +28,4 @@ app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
app.Run();
@@ -1,23 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7035;http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7035;http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -1,8 +1,8 @@
using System.Text.RegularExpressions;
using Cloud;
using DatabaseTest.Models;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Cloud.Models;
using Npgsql;
namespace DatabaseTest.Services;
@@ -23,89 +23,118 @@ public interface IDatabaseConfigService
public class DatabaseConfigService : IDatabaseConfigService
{
private readonly IConfiguration _configuration;
private DatabaseProviderType _providerType;
private string _connectionString;
private string _inMemoryDbName = "chrono_database_test";
public event Action? OnConfigurationChanged;
public DatabaseConfigService(IConfiguration configuration)
{
_configuration = configuration;
var envPassword = Environment.GetEnvironmentVariable("PostgreSQL_Password")
?? (OperatingSystem.IsWindows()
? Environment.GetEnvironmentVariable("PostgreSQL_Password",
EnvironmentVariableTarget.User)
: null)
?? (OperatingSystem.IsWindows()
? Environment.GetEnvironmentVariable("PostgreSQL_Password",
EnvironmentVariableTarget.Machine)
: null)
?? Environment.GetEnvironmentVariable("POSTGRESQL_PASSWORD")
?? Environment.GetEnvironmentVariable("POSTGRES_PASSWORD");
var configuredConn = _configuration.GetConnectionString("DefaultConnection")
?? _configuration.GetConnectionString("Postgres")
?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? "Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres";
?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING");
_connectionString = configuredConn;
_providerType = DatabaseProviderType.PostgreSQL;
if (string.IsNullOrWhiteSpace(configuredConn))
{
var csb = new NpgsqlConnectionStringBuilder
{
Host = "localhost",
Database = "chrono",
Username = "postgres",
Password = envPassword
};
configuredConn = csb.ConnectionString;
}
else if (!string.IsNullOrEmpty(envPassword))
{
try
{
var csb = new NpgsqlConnectionStringBuilder(configuredConn)
{
Password = envPassword
};
configuredConn = csb.ConnectionString;
}
catch
{
}
}
ConnectionString = configuredConn;
ProviderType = DatabaseProviderType.PostgreSQL;
}
public DatabaseProviderType ProviderType => _providerType;
public string ConnectionString => _connectionString;
public string InMemoryDbName => _inMemoryDbName;
public event Action? OnConfigurationChanged;
public DatabaseProviderType ProviderType { get; private set; }
public string ConnectionString { get; private set; }
public string InMemoryDbName { get; private set; } = "chrono_database_test";
public void SetProvider(DatabaseProviderType providerType)
{
if (_providerType != providerType)
if (ProviderType != providerType)
{
_providerType = providerType;
ProviderType = providerType;
OnConfigurationChanged?.Invoke();
}
}
public void SetConnectionString(string connectionString)
{
if (_connectionString != connectionString)
if (ConnectionString != connectionString)
{
_connectionString = connectionString;
ConnectionString = connectionString;
OnConfigurationChanged?.Invoke();
}
}
public void SetInMemoryDbName(string name)
{
if (_inMemoryDbName != name)
if (InMemoryDbName != name)
{
_inMemoryDbName = name;
InMemoryDbName = name;
OnConfigurationChanged?.Invoke();
}
}
public string GetActiveProviderDescription()
{
return _providerType switch
return ProviderType switch
{
DatabaseProviderType.PostgreSQL => $"PostgreSQL ({GetMaskedConnectionString(_connectionString)})",
DatabaseProviderType.InMemory => $"In-Memory Test Database ({_inMemoryDbName})",
_ => _providerType.ToString()
DatabaseProviderType.PostgreSQL => $"PostgreSQL ({GetMaskedConnectionString(ConnectionString)})",
DatabaseProviderType.InMemory => $"In-Memory Test Database ({InMemoryDbName})",
_ => ProviderType.ToString()
};
}
private static string GetMaskedConnectionString(string conn)
{
if (string.IsNullOrWhiteSpace(conn)) return "Not configured";
// Hide password in display
return System.Text.RegularExpressions.Regex.Replace(conn, @"(?i)Password=[^;]+", "Password=******");
}
public AppDbContext CreateDbContext()
{
var builder = new DbContextOptionsBuilder<AppDbContext>();
if (_providerType == DatabaseProviderType.PostgreSQL)
{
builder.UseNpgsql(_connectionString, npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(15);
});
}
if (ProviderType == DatabaseProviderType.PostgreSQL)
builder.UseNpgsql(ConnectionString, npgsqlOptions => { npgsqlOptions.CommandTimeout(15); });
else
{
builder.UseInMemoryDatabase(_inMemoryDbName);
}
builder.UseInMemoryDatabase(InMemoryDbName);
return new AppDbContext(builder.Options);
}
}
private static string GetMaskedConnectionString(string conn)
{
if (string.IsNullOrWhiteSpace(conn)) return "Not configured";
// Hide password in display
return Regex.Replace(conn, @"(?i)Password=[^;]+", "Password=******");
}
}
+316 -111
View File
@@ -1,6 +1,4 @@
using System.Diagnostics;
using System.Text.Json;
using Cloud;
using Cloud.Models;
using DatabaseTest.Models;
using Microsoft.EntityFrameworkCore;
@@ -17,11 +15,17 @@ public interface IDatabaseService
Task<OperationResult> ResetDatabaseAsync();
Task<SeedResult> SeedSampleDataAsync();
// Users
Task<List<User>> GetUsersAsync(string? search = null);
Task<User?> GetUserAsync(string username);
Task<OperationResult> SaveUserAsync(User user, bool isNew);
Task<OperationResult> DeleteUserAsync(string username);
// Card Notes
Task<List<CardNote>> GetCardNotesAsync(string? search = null);
Task<CardNote?> GetCardNoteAsync(string cardName);
Task<List<CardNote>> GetCardNotesAsync(string? search = null, string? username = null);
Task<CardNote?> GetCardNoteAsync(string cardName, string username = "");
Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew);
Task<OperationResult> DeleteCardNoteAsync(string cardName);
Task<OperationResult> DeleteCardNoteAsync(string cardName, string username = "");
// User Decks
Task<List<UserDeck>> GetUserDecksAsync(string? search = null);
@@ -84,10 +88,12 @@ public class DatabaseService : IDatabaseService
status.CardNotesCount = await db.CardNotes.CountAsync();
status.UserDecksCount = await db.UserDecks.CountAsync();
status.PasskeysCount = await db.PasskeyCredentials.CountAsync();
status.UsersCount = await db.Users.CountAsync();
}
catch (Exception ex)
{
status.ErrorMessage = $"Connected, but table count failed (migrations may be pending): {ex.Message}";
status.ErrorMessage =
$"Connected, but table count failed (migrations may be pending): {ex.Message}";
}
}
else
@@ -143,18 +149,15 @@ public class DatabaseService : IDatabaseService
{
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
if (!pending.Any())
{
return OperationResult.Ok("Database schema is already up-to-date. No pending migrations.");
}
await db.Database.MigrateAsync();
return OperationResult.Ok($"Successfully applied {pending.Count} migration(s): {string.Join(", ", pending)}");
}
else
{
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database schema ensured.");
return OperationResult.Ok(
$"Successfully applied {pending.Count} migration(s): {string.Join(", ", pending)}");
}
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database schema ensured.");
}
catch (Exception ex)
{
@@ -189,15 +192,14 @@ public class DatabaseService : IDatabaseService
db.CardNotes.RemoveRange(db.CardNotes);
db.UserDecks.RemoveRange(db.UserDecks);
db.PasskeyCredentials.RemoveRange(db.PasskeyCredentials);
db.Users.RemoveRange(db.Users);
await db.SaveChangesAsync();
return OperationResult.Ok("All database tables cleared successfully.");
}
else
{
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database wiped and recreated successfully.");
}
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
return OperationResult.Ok("In-memory database wiped and recreated successfully.");
}
catch (Exception ex)
{
@@ -213,26 +215,58 @@ public class DatabaseService : IDatabaseService
{
await using var db = _configService.CreateDbContext();
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
await db.Database.MigrateAsync();
}
else
{
await db.Database.EnsureCreatedAsync();
// 0. Seed Users
var existingUsernames = (await db.Users.Select(u => u.Username).ToListAsync()).ToHashSet();
var sampleUsers = new List<User>
{
new() { Username = "admin", Password = "password123", CreatedAt = DateTime.UtcNow },
new() { Username = "testuser", Password = "password123", CreatedAt = DateTime.UtcNow },
new() { Username = "player1", Password = "password123", CreatedAt = DateTime.UtcNow }
};
foreach (var user in sampleUsers.Where(u => !existingUsernames.Contains(u.Username)))
{
db.Users.Add(user);
result.UsersSeeded++;
}
// 1. Seed Card Notes
var existingNoteNames = (await db.CardNotes.Select(n => n.CardName).ToListAsync()).ToHashSet();
var existingNotes =
(await db.CardNotes.Select(n => new { n.Username, n.CardName }).ToListAsync()).ToHashSet();
var sampleNotes = new List<CardNote>
{
new() { CardName = "Kaelen, Arcane Weaver", Note = "Key combo enabler for control decks. High priority removal target." },
new() { CardName = "Valkyrie Ascendant", Note = "Strong mid-game aerial presence. Synergizes well with Divine Shield buffs." },
new() { CardName = "Chronos, Time Shifter", Note = "Turn rewind capability allows recovering from early tempo loss." },
new() { CardName = "Cyber Samurai", Note = "Fast aggro card with stealth. Great opener." },
new() { CardName = "Temporal Rift", Note = "Essential spell for board reset in late game." }
new()
{
Username = "admin", CardName = "Kaelen, Arcane Weaver",
Note = "Key combo enabler for control decks. High priority removal target."
},
new()
{
Username = "admin", CardName = "Valkyrie Ascendant",
Note = "Strong mid-game aerial presence. Synergizes well with Divine Shield buffs."
},
new()
{
Username = "testuser", CardName = "Chronos, Time Shifter",
Note = "Turn rewind capability allows recovering from early tempo loss."
},
new()
{
Username = "testuser", CardName = "Cyber Samurai",
Note = "Fast aggro card with stealth. Great opener."
},
new()
{
Username = "player1", CardName = "Temporal Rift",
Note = "Essential spell for board reset in late game."
}
};
foreach (var note in sampleNotes.Where(n => !existingNoteNames.Contains(n.CardName)))
foreach (var note in sampleNotes.Where(n => !existingNotes.Contains(new { n.Username, n.CardName })))
{
db.CardNotes.Add(note);
result.CardNotesSeeded++;
@@ -295,16 +329,32 @@ public class DatabaseService : IDatabaseService
{
new()
{
CredentialId = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80 },
PublicKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07 },
CredentialId = new byte[]
{
0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70,
0x80
},
PublicKey = new byte[]
{
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08,
0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07
},
UserHandle = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 },
AaGuid = Guid.NewGuid().ToString(),
SignCount = 42
},
new()
{
CredentialId = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 },
PublicKey = new byte[] { 0x04, 0x1F, 0x2E, 0x3D, 0x4C, 0x5B, 0x6A, 0x79, 0x88, 0x97, 0xA6, 0xB5, 0xC4, 0xD3, 0xE2, 0xF1 },
CredentialId = new byte[]
{
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x00
},
PublicKey = new byte[]
{
0x04, 0x1F, 0x2E, 0x3D, 0x4C, 0x5B, 0x6A, 0x79, 0x88, 0x97, 0xA6, 0xB5, 0xC4, 0xD3, 0xE2,
0xF1
},
UserHandle = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x05, 0x06, 0x07, 0x08 },
AaGuid = Guid.NewGuid().ToString(),
SignCount = 100
@@ -317,7 +367,8 @@ public class DatabaseService : IDatabaseService
await db.SaveChangesAsync();
result.Success = true;
result.Message = $"Sample data seeded: {result.CardNotesSeeded} notes, {result.UserDecksSeeded} decks, {result.PasskeysSeeded} passkeys.";
result.Message =
$"Sample data seeded: {result.UsersSeeded} users, {result.CardNotesSeeded} notes, {result.UserDecksSeeded} decks, {result.PasskeysSeeded} passkeys.";
}
catch (Exception ex)
{
@@ -329,26 +380,109 @@ public class DatabaseService : IDatabaseService
return result;
}
// ── Card Notes CRUD ────────────────────────────────────────────────────────
// ── Users CRUD ─────────────────────────────────────────────────────────────
public async Task<List<CardNote>> GetCardNotesAsync(string? search = null)
public async Task<List<User>> GetUsersAsync(string? search = null)
{
await using var db = _configService.CreateDbContext();
var query = db.CardNotes.AsNoTracking().AsQueryable();
var query = db.Users.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(n => n.CardName.ToLower().Contains(s) || n.Note.ToLower().Contains(s));
query = query.Where(u => u.Username.ToLower().Contains(s));
}
return await query.OrderBy(u => u.Username).ToListAsync();
}
public async Task<User?> GetUserAsync(string username)
{
await using var db = _configService.CreateDbContext();
return await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Username == username);
}
public async Task<OperationResult> SaveUserAsync(User user, bool isNew)
{
if (string.IsNullOrWhiteSpace(user.Username))
return OperationResult.Fail("Username is required.");
if (string.IsNullOrWhiteSpace(user.Password))
return OperationResult.Fail("Password is required.");
try
{
await using var db = _configService.CreateDbContext();
if (isNew)
{
var exists = await db.Users.AnyAsync(u => u.Username == user.Username);
if (exists)
return OperationResult.Fail($"User '{user.Username}' already exists.");
user.CreatedAt = DateTime.UtcNow;
db.Users.Add(user);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created user '{user.Username}'.");
}
var existing = await db.Users.FirstOrDefaultAsync(u => u.Username == user.Username);
if (existing == null)
return OperationResult.Fail($"User '{user.Username}' was not found.");
existing.Password = user.Password;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated user '{user.Username}'.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save user {Username}", user.Username);
return OperationResult.Fail($"Error saving user: {ex.Message}", ex.ToString());
}
}
public async Task<OperationResult> DeleteUserAsync(string username)
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (existing == null)
return OperationResult.Fail($"User '{username}' not found.");
db.Users.Remove(existing);
await db.SaveChangesAsync();
return OperationResult.Ok($"Deleted user '{username}'.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete user {Username}", username);
return OperationResult.Fail($"Error deleting user: {ex.Message}", ex.ToString());
}
}
// ── Card Notes CRUD ────────────────────────────────────────────────────────
public async Task<List<CardNote>> GetCardNotesAsync(string? search = null, string? username = null)
{
await using var db = _configService.CreateDbContext();
var query = db.CardNotes.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(username)) query = query.Where(n => n.Username == username);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(n =>
n.CardName.ToLower().Contains(s) || n.Note.ToLower().Contains(s) || n.Username.ToLower().Contains(s));
}
return await query.OrderBy(n => n.CardName).ToListAsync();
}
public async Task<CardNote?> GetCardNoteAsync(string cardName)
public async Task<CardNote?> GetCardNoteAsync(string cardName, string username = "")
{
await using var db = _configService.CreateDbContext();
return await db.CardNotes.AsNoTracking().FirstOrDefaultAsync(n => n.CardName == cardName);
return await db.CardNotes.AsNoTracking()
.FirstOrDefaultAsync(n => n.CardName == cardName && n.Username == (username ?? ""));
}
public async Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew)
@@ -356,29 +490,35 @@ public class DatabaseService : IDatabaseService
if (string.IsNullOrWhiteSpace(note.CardName))
return OperationResult.Fail("Card name is required.");
note.Username ??= "";
try
{
await using var db = _configService.CreateDbContext();
if (isNew)
{
var exists = await db.CardNotes.AnyAsync(n => n.CardName == note.CardName);
var exists =
await db.CardNotes.AnyAsync(n => n.CardName == note.CardName && n.Username == note.Username);
if (exists)
return OperationResult.Fail($"A note for card '{note.CardName}' already exists.");
return OperationResult.Fail(
$"A note for card '{note.CardName}' for user '{note.Username}' already exists.");
note.UpdatedAt = DateTime.UtcNow;
db.CardNotes.Add(note);
await db.SaveChangesAsync();
return OperationResult.Ok($"Created note for '{note.CardName}'.");
}
else
{
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == note.CardName);
if (existing == null)
return OperationResult.Fail($"Note for card '{note.CardName}' was not found.");
existing.Note = note.Note;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated note for '{note.CardName}'.");
}
var existing =
await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == note.CardName && n.Username == note.Username);
if (existing == null)
return OperationResult.Fail(
$"Note for card '{note.CardName}' for user '{note.Username}' was not found.");
existing.Note = note.Note;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated note for '{note.CardName}'.");
}
catch (Exception ex)
{
@@ -387,12 +527,13 @@ public class DatabaseService : IDatabaseService
}
}
public async Task<OperationResult> DeleteCardNoteAsync(string cardName)
public async Task<OperationResult> DeleteCardNoteAsync(string cardName, string username = "")
{
try
{
await using var db = _configService.CreateDbContext();
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == cardName);
var existing =
await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == cardName && n.Username == (username ?? ""));
if (existing == null)
return OperationResult.Fail($"Note for '{cardName}' not found.");
@@ -450,22 +591,20 @@ public class DatabaseService : IDatabaseService
await db.SaveChangesAsync();
return OperationResult.Ok($"Created deck '{deck.Name}'.");
}
else
{
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == deck.Id);
if (existing == null)
return OperationResult.Fail($"Deck with ID {deck.Id} was not found.");
existing.Name = deck.Name;
existing.Notes = deck.Notes;
existing.Season = deck.Season;
existing.Cards = deck.Cards ?? [];
existing.Divers = deck.Divers ?? [];
existing.UpdatedAt = DateTime.UtcNow;
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == deck.Id);
if (existing == null)
return OperationResult.Fail($"Deck with ID {deck.Id} was not found.");
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated deck '{deck.Name}'.");
}
existing.Name = deck.Name;
existing.Notes = deck.Notes;
existing.Season = deck.Season;
existing.Cards = deck.Cards ?? [];
existing.Divers = deck.Divers ?? [];
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated deck '{deck.Name}'.");
}
catch (Exception ex)
{
@@ -537,21 +676,19 @@ public class DatabaseService : IDatabaseService
await db.SaveChangesAsync();
return OperationResult.Ok($"Created Passkey credential #{passkey.Id}.");
}
else
{
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == passkey.Id);
if (existing == null)
return OperationResult.Fail($"Passkey with ID {passkey.Id} was not found.");
existing.CredentialId = passkey.CredentialId;
existing.PublicKey = passkey.PublicKey;
existing.UserHandle = passkey.UserHandle;
existing.AaGuid = passkey.AaGuid;
existing.SignCount = passkey.SignCount;
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == passkey.Id);
if (existing == null)
return OperationResult.Fail($"Passkey with ID {passkey.Id} was not found.");
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated Passkey credential #{passkey.Id}.");
}
existing.CredentialId = passkey.CredentialId;
existing.PublicKey = passkey.PublicKey;
existing.UserHandle = passkey.UserHandle;
existing.AaGuid = passkey.AaGuid;
existing.SignCount = passkey.SignCount;
await db.SaveChangesAsync();
return OperationResult.Ok($"Updated Passkey credential #{passkey.Id}.");
}
catch (Exception ex)
{
@@ -587,8 +724,8 @@ public class DatabaseService : IDatabaseService
var results = new List<DatabaseTestCaseResult>();
// 1. Connection Test
results.Add(await RunTestCaseAsync("DB-01", "Database Connectivity", "Connection",
"Validates socket/connection pool access to the target database server.",
results.Add(await RunTestCaseAsync("DB-01", "Database Connectivity", "Connection",
"Validates socket/connection pool access to the target database server.",
async test =>
{
test.AddLog($"Connecting to {_configService.GetActiveProviderDescription()}...");
@@ -601,8 +738,8 @@ public class DatabaseService : IDatabaseService
}, progressCallback));
// 2. Migrations & Schema Check
results.Add(await RunTestCaseAsync("DB-02", "Schema & Migrations", "Schema",
"Verifies applied EF Core migrations and checks that all tables exist.",
results.Add(await RunTestCaseAsync("DB-02", "Schema & Migrations", "Schema",
"Verifies applied EF Core migrations and checks that all tables exist.",
async test =>
{
await using var db = _configService.CreateDbContext();
@@ -634,18 +771,20 @@ public class DatabaseService : IDatabaseService
}, progressCallback));
// 3. CardNote CRUD Test
results.Add(await RunTestCaseAsync("DB-03", "CardNote Full CRUD Lifecycle", "CardNotes",
"Tests Insert, Query, Update, and Delete operations on the CardNotes entity.",
results.Add(await RunTestCaseAsync("DB-03", "CardNote Full CRUD Lifecycle", "CardNotes",
"Tests Insert, Query, Update, and Delete operations on the CardNotes entity.",
async test =>
{
var testCardName = $"__Test_Card_{Guid.NewGuid():N}";
var testUsername = "testuser";
var initialNote = "Initial test note description";
var updatedNote = "Updated test note content with extra details";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Creating test CardNote for '{testCardName}'...");
db.CardNotes.Add(new CardNote { CardName = testCardName, Note = initialNote });
test.AddLog($"1. Creating test CardNote for '{testCardName}' (user: '{testUsername}')...");
db.CardNotes.Add(new CardNote
{ Username = testUsername, CardName = testCardName, Note = initialNote });
await db.SaveChangesAsync();
test.AddLog("CardNote inserted successfully.");
}
@@ -653,7 +792,8 @@ public class DatabaseService : IDatabaseService
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Querying inserted CardNote from database...");
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
var fetched = await db.CardNotes.FirstOrDefaultAsync(n =>
n.CardName == testCardName && n.Username == testUsername);
if (fetched == null || fetched.Note != initialNote)
throw new Exception($"Expected note '{initialNote}', but retrieved '{fetched?.Note}'");
test.AddLog("CardNote retrieved and validated.");
@@ -667,7 +807,8 @@ public class DatabaseService : IDatabaseService
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying persisted updated note...");
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
var fetched = await db.CardNotes.FirstOrDefaultAsync(n =>
n.CardName == testCardName && n.Username == testUsername);
if (fetched == null || fetched.Note != updatedNote)
throw new Exception($"Expected updated note '{updatedNote}', but found '{fetched?.Note}'");
test.AddLog("Update persistence verified.");
@@ -681,7 +822,8 @@ public class DatabaseService : IDatabaseService
await using (var db = _configService.CreateDbContext())
{
test.AddLog("6. Verifying CardNote is deleted...");
var exists = await db.CardNotes.AnyAsync(n => n.CardName == testCardName);
var exists =
await db.CardNotes.AnyAsync(n => n.CardName == testCardName && n.Username == testUsername);
if (exists)
throw new Exception("CardNote still exists after deletion.");
test.AddLog("CardNote deletion verified.");
@@ -689,8 +831,8 @@ public class DatabaseService : IDatabaseService
}, progressCallback));
// 4. UserDeck CRUD & JSONB Serialization Test
results.Add(await RunTestCaseAsync("DB-04", "UserDeck CRUD & JSONB Mapping", "UserDecks",
"Validates JSON column mapping for Cards and Divers collections in PostgreSQL/EF Core.",
results.Add(await RunTestCaseAsync("DB-04", "UserDeck CRUD & JSONB Mapping", "UserDecks",
"Validates JSON column mapping for Cards and Divers collections in PostgreSQL/EF Core.",
async test =>
{
var testDeckId = Guid.NewGuid();
@@ -708,7 +850,8 @@ public class DatabaseService : IDatabaseService
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Inserting UserDeck '{testDeckName}' with {initialCards.Count} cards and {initialDivers.Count} divers...");
test.AddLog(
$"1. Inserting UserDeck '{testDeckName}' with {initialCards.Count} cards and {initialDivers.Count} divers...");
var deck = new UserDeck
{
Id = testDeckId,
@@ -732,7 +875,7 @@ public class DatabaseService : IDatabaseService
if (fetched.Cards == null || fetched.Cards.Count != 5)
throw new Exception($"Expected 5 cards in deck, found {fetched.Cards?.Count ?? 0}");
if (fetched.Divers == null || fetched.Divers.Count != 1 || fetched.Divers[0] != "Agent Alpha")
throw new Exception($"Diver entry deserialization mismatch.");
throw new Exception("Diver entry deserialization mismatch.");
test.AddLog("JSON properties successfully verified.");
test.AddLog("3. Modifying cards list in UserDeck...");
@@ -766,8 +909,8 @@ public class DatabaseService : IDatabaseService
}, progressCallback));
// 5. PasskeyCredential Binary Handling Test
results.Add(await RunTestCaseAsync("DB-05", "PasskeyCredential Binary Storage", "Passkeys",
"Validates byte array (byte[]) handling for CredentialId, PublicKey, and UserHandle.",
results.Add(await RunTestCaseAsync("DB-05", "PasskeyCredential Binary Storage", "Passkeys",
"Validates byte array (byte[]) handling for CredentialId, PublicKey, and UserHandle.",
async test =>
{
var credId = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xDD, 0x10, 0x20, 0x30, 0x40 };
@@ -775,7 +918,7 @@ public class DatabaseService : IDatabaseService
var userHandle = new byte[] { 0xFE, 0xDC, 0xBA, 0x98 };
var aaGuid = Guid.NewGuid().ToString();
long signCount = 100;
int savedId = 0;
var savedId = 0;
await using (var db = _configService.CreateDbContext())
{
@@ -838,15 +981,17 @@ public class DatabaseService : IDatabaseService
}, progressCallback));
// 6. Primary Key & Duplicate Key Constraint Test
results.Add(await RunTestCaseAsync("DB-06", "Primary Key & Duplicate Constraints", "Integrity",
"Verifies that attempting to insert a duplicate primary key throws DbUpdateException.",
results.Add(await RunTestCaseAsync("DB-06", "Primary Key & Duplicate Constraints", "Integrity",
"Verifies that attempting to insert a duplicate primary key throws DbUpdateException.",
async test =>
{
var duplicateKey = $"__DupTest_{Guid.NewGuid():N}";
var testUser = "testuser";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Inserting initial record with key '{duplicateKey}'...");
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "First note" });
db.CardNotes.Add(new CardNote
{ Username = testUser, CardName = duplicateKey, Note = "First note" });
await db.SaveChangesAsync();
test.AddLog("Initial record created.");
}
@@ -856,7 +1001,8 @@ public class DatabaseService : IDatabaseService
await using (var db = _configService.CreateDbContext())
{
test.AddLog("2. Attempting to insert duplicate record with same primary key...");
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "Second duplicate note" });
db.CardNotes.Add(new CardNote
{ Username = testUser, CardName = duplicateKey, Note = "Second duplicate note" });
await db.SaveChangesAsync();
}
@@ -878,7 +1024,9 @@ public class DatabaseService : IDatabaseService
{
// Cleanup
await using var db = _configService.CreateDbContext();
var toClean = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == duplicateKey);
var toClean =
await db.CardNotes.FirstOrDefaultAsync(n =>
n.CardName == duplicateKey && n.Username == testUser);
if (toClean != null)
{
db.CardNotes.Remove(toClean);
@@ -889,24 +1037,27 @@ public class DatabaseService : IDatabaseService
}, progressCallback));
// 7. Transaction Rollback Verification
results.Add(await RunTestCaseAsync("DB-07", "Transaction Rollback Support", "Transactions",
"Verifies that uncommitted transactions roll back changes cleanly on failure.",
results.Add(await RunTestCaseAsync("DB-07", "Transaction Rollback Support", "Transactions",
"Verifies that uncommitted transactions roll back changes cleanly on failure.",
async test =>
{
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
{
test.AddLog("Transaction testing requires relational provider (PostgreSQL). Skipping for In-Memory.");
test.AddLog(
"Transaction testing requires relational provider (PostgreSQL). Skipping for In-Memory.");
return;
}
var transCard = $"__Trans_{Guid.NewGuid():N}";
var testUser = "testuser";
await using (var db = _configService.CreateDbContext())
{
test.AddLog("1. Beginning transaction...");
await using var transaction = await db.Database.BeginTransactionAsync();
test.AddLog($"2. Inserting card '{transCard}' inside transaction...");
db.CardNotes.Add(new CardNote { CardName = transCard, Note = "Transaction Note" });
db.CardNotes.Add(new CardNote
{ Username = testUser, CardName = transCard, Note = "Transaction Note" });
await db.SaveChangesAsync();
test.AddLog("3. Rolling back transaction explicitly without committing...");
@@ -917,13 +1068,67 @@ public class DatabaseService : IDatabaseService
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying that the rolled back card note was NOT persisted...");
var exists = await db.CardNotes.AnyAsync(n => n.CardName == transCard);
var exists = await db.CardNotes.AnyAsync(n => n.CardName == transCard && n.Username == testUser);
if (exists)
throw new Exception("Record was persisted despite transaction rollback!");
test.AddLog("Rollback verified: record does not exist in database.");
}
}, progressCallback));
// 8. User Management CRUD Test
results.Add(await RunTestCaseAsync("DB-08", "User Entity CRUD & Querying", "Users",
"Tests creating, retrieving, updating password, and deleting User records.",
async test =>
{
var testUsername = $"user_{Guid.NewGuid():N}";
var initialPassword = "initialPassword123";
var updatedPassword = "newSecurePassword456";
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"1. Creating user '{testUsername}'...");
db.Users.Add(new User
{ Username = testUsername, Password = initialPassword, CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
test.AddLog("User created.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog($"2. Querying user '{testUsername}'...");
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == testUsername);
if (user == null || user.Password != initialPassword)
throw new Exception("User not found or password mismatch.");
test.AddLog("User query verified.");
test.AddLog("3. Updating user password...");
user.Password = updatedPassword;
await db.SaveChangesAsync();
test.AddLog("User password updated.");
}
await using (var db = _configService.CreateDbContext())
{
test.AddLog("4. Verifying updated password...");
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == testUsername);
if (user == null || user.Password != updatedPassword)
throw new Exception("Updated password did not persist.");
test.AddLog("Password update verified.");
test.AddLog("5. Deleting test user...");
db.Users.Remove(user);
await db.SaveChangesAsync();
test.AddLog("User deleted.");
}
await using (var db = _configService.CreateDbContext())
{
var exists = await db.Users.AnyAsync(u => u.Username == testUsername);
if (exists) throw new Exception("User still exists after deletion.");
test.AddLog("User deletion confirmed.");
}
}, progressCallback));
return results;
}
@@ -969,4 +1174,4 @@ public class DatabaseService : IDatabaseService
return test;
}
}
}
+4 -1
View File
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=chrono;Username=postgres;"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,427 +5,435 @@
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
margin-bottom: 0;
}
dt {
font-weight: 700;
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
font-weight: bolder;
}
small {
font-size: 0.875em;
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
bottom: -0.25em;
}
sup {
top: -0.5em;
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
@@ -434,21 +442,21 @@ tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
display: inline-block;
}
button {
border-radius: 0;
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
outline: 0;
}
input,
@@ -456,76 +464,80 @@ button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
text-transform: none;
}
[role=button] {
cursor: pointer;
cursor: pointer;
}
select {
word-wrap: normal;
word-wrap: normal;
}
select:disabled {
opacity: 1;
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
@@ -535,16 +547,16 @@ legend + * {
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
@@ -556,42 +568,42 @@ legend + * {
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
display: inline-block;
}
iframe {
border: 0;
border: 0;
}
summary {
display: list-item;
cursor: pointer;
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
vertical-align: baseline;
}
[hidden] {
display: none !important;
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */
@@ -5,427 +5,435 @@
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
margin-bottom: 0;
}
dt {
font-weight: 700;
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
font-weight: bolder;
}
small {
font-size: 0.875em;
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
bottom: -0.25em;
}
sup {
top: -0.5em;
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
@@ -434,21 +442,21 @@ tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
display: inline-block;
}
button {
border-radius: 0;
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
outline: 0;
}
input,
@@ -456,76 +464,80 @@ button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
text-transform: none;
}
[role=button] {
cursor: pointer;
cursor: pointer;
}
select {
word-wrap: normal;
word-wrap: normal;
}
select:disabled {
opacity: 1;
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
@@ -535,60 +547,62 @@ legend + * {
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
display: inline-block;
}
iframe {
border: 0;
border: 0;
}
summary {
display: list-item;
cursor: pointer;
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
vertical-align: baseline;
}
[hidden] {
display: none !important;
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff