API Test
This commit is contained in:
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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=******");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=chrono;Username=postgres;"
|
||||
}
|
||||
}
|
||||
|
||||
+4416
-3450
File diff suppressed because it is too large
Load Diff
+4417
-3450
File diff suppressed because it is too large
Load Diff
+339
-327
@@ -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 */
|
||||
+342
-328
@@ -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 */
|
||||
+4740
-3832
File diff suppressed because it is too large
Load Diff
+4740
-3830
File diff suppressed because it is too large
Load Diff
+10002
-8338
File diff suppressed because it is too large
Load Diff
+10012
-8337
File diff suppressed because it is too large
Load Diff
+6340
-6144
File diff suppressed because it is too large
Load Diff
+3421
-3232
File diff suppressed because it is too large
Load Diff
+4602
-4412
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user