This commit is contained in:
6d486f49
2026-08-14 15:56:17 -04:00
parent af64b9e95c
commit 09a8adfc4a
827 changed files with 65045 additions and 71849 deletions
@@ -0,0 +1,343 @@
@page "/card-notes"
@implements IDisposable
<PageTitle>Card Notes 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">Card Notes Database (`CardNote`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete notes associated with Chrono CCG cards.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadNotesAsync">
<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 Note
</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 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>
}
</div>
<div class="text-secondary small">
Showing @notes.Count note(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 card notes from database...</div>
</div>
}
else if (!notes.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-journal-x fs-1 d-block mb-2"></i>
<h5>No Card Notes Found</h5>
<p class="small">There are currently no card notes matching your criteria.</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 Note
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSampleNotesAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Notes
</button>
</div>
</div>
}
else
{
<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>
</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>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? "Edit Card Note" : "Add New Card Note")
</h5>
<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">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>
}
else
{
<input type="text" class="form-control form-control-chrono" list="cardSuggestions"
placeholder="Enter or select card name..." @bind="currentNote.CardName" />
<datalist id="cardSuggestions">
@foreach (var c in availableCardNames.Take(50))
{
<option value="@c" />
}
</datalist>
<div class="form-text text-secondary">Select an existing card from the database or enter a custom name.</div>
}
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">STRATEGIC NOTE</label>
<textarea class="form-control form-control-chrono" rows="5"
placeholder="Enter strategic card observations, deck synergies, mulligan advice, or balancing notes..."
@bind="currentNote.Note"></textarea>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveNoteAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Note")
</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && noteToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<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>
</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>
</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-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Permanently")
</button>
</div>
</div>
</div>
}
@code {
private List<CardNote> notes = [];
private List<string> availableCardNames = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private CardNote currentNote = new();
private bool showDeleteModal;
private CardNote? noteToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
try
{
availableCardNames = CardDatabase.Cards
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
}
catch
{
availableCardNames = [];
}
await LoadNotesAsync();
}
private void HandleConfigChanged()
{
_ = LoadNotesAsync();
}
private async Task LoadNotesAsync()
{
isLoading = true;
try
{
notes = await DbService.GetCardNotesAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading card notes: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterNotes()
{
notes = await DbService.GetCardNotesAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadNotesAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentNote = new CardNote();
showModal = true;
}
private void OpenEditModal(CardNote note)
{
isEditing = true;
currentNote = new CardNote
{
CardName = note.CardName,
Note = note.Note
};
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private async Task SaveNoteAsync()
{
if (string.IsNullOrWhiteSpace(currentNote.CardName))
{
alertIsSuccess = false;
alertMessage = "Card name cannot be empty.";
return;
}
isSaving = true;
var result = await DbService.SaveCardNoteAsync(currentNote, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadNotesAsync();
}
}
private void OpenDeleteModal(CardNote note)
{
noteToDelete = note;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (noteToDelete == null) return;
isSaving = true;
var result = await DbService.DeleteCardNoteAsync(noteToDelete.CardName);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
noteToDelete = null;
await LoadNotesAsync();
}
private async Task SeedSampleNotesAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadNotesAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,20 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
@@ -0,0 +1,360 @@
@page "/diagnostics"
@implements IDisposable
<PageTitle>Database Configuration & Migrations - Chrono DB</PageTitle>
<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>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh Status
</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="row g-4 mb-4">
<!-- Connection Configuration -->
<div class="col-lg-6">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-hdd-network-fill text-primary"></i>
Database Provider & Connection
</h5>
</div>
<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)">
<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)">
<i class="bi bi-cpu me-1"></i> In-Memory Test DB
</button>
</div>
</div>
@if (selectedProvider == DatabaseProviderType.PostgreSQL)
{
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">POSTGRESQL CONNECTION STRING</label>
<textarea class="form-control form-control-chrono font-monospace" rows="3"
placeholder="Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres"
@bind="connectionStringInput"></textarea>
<div class="form-text text-secondary">
Standard Npgsql connection string format used by <code>Cloud.AppDbContext</code>.
</div>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">CONNECTION PRESETS</label>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick='() => SetPreset("Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres")'>
Default Localhost (5432)
</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick='() => SetPreset("Host=host.docker.internal;Port=5432;Database=chrono;Username=postgres;Password=postgres")'>
Docker Host Internal
</button>
</div>
</div>
}
else
{
<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" />
<div class="form-text text-secondary">
In-memory EF Core provider creates an isolated database in RAM for instant testing.
</div>
</div>
}
<div class="d-flex justify-content-end gap-2 mt-4">
<button class="btn btn-chrono-primary" @onclick="SaveConfigurationAsync" disabled="@isSaving">
<i class="bi bi-check2-circle me-1"></i>
@(isSaving ? "Connecting..." : "Save & Test Connection")
</button>
</div>
</div>
</div>
<!-- Live Database Diagnostics & Table Counts -->
<div class="col-lg-6">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-activity text-info"></i>
Database Health & Entity Counts
</h5>
<span class="badge @(health?.IsConnected == true ? "bg-success" : "bg-danger")">
@(health?.IsConnected == true ? "ONLINE" : "OFFLINE")
</span>
</div>
@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">
<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">
<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">
<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">
<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">
<span class="text-secondary small">PasskeyCredentials Table Rows</span>
<span class="text-purple fw-bold">@health.PasskeysCount</span>
</div>
</div>
@if (!health.IsConnected && !string.IsNullOrEmpty(health.ErrorMessage))
{
<div class="alert alert-danger p-2 small mb-0">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
@health.ErrorMessage
</div>
}
}
</div>
</div>
</div>
<!-- EF Core Migrations Section -->
<div class="chrono-card">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-layer-forward text-warning"></i>
EF Core Migrations Status
</h5>
@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)
</button>
}
</div>
@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>
}
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.
</div>
}
else
{
<div class="row g-4">
<!-- Applied Migrations -->
<div class="col-md-6">
<h6 class="text-secondary small fw-bold mb-2">
<i class="bi bi-check2-all text-success me-1"></i>
APPLIED MIGRATIONS (@migrationReport.AppliedMigrations.Count)
</h6>
@if (!migrationReport.AppliedMigrations.Any())
{
<div class="text-secondary small fst-italic p-2 bg-dark rounded border border-secondary">
No migrations have been applied to this database yet.
</div>
}
else
{
<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">
<span class="text-white small font-monospace">@m</span>
<span class="badge bg-success"><i class="bi bi-check"></i> Applied</span>
</div>
}
</div>
}
</div>
<!-- Pending Migrations -->
<div class="col-md-6">
<h6 class="text-secondary small fw-bold mb-2">
<i class="bi bi-clock-history text-warning me-1"></i>
PENDING MIGRATIONS (@migrationReport.PendingMigrations.Count)
</h6>
@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.
</div>
}
else
{
<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">
<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>
}
</div>
}
</div>
</div>
}
</div>
<!-- Database Maintenance Section -->
<div class="chrono-card">
<div class="chrono-card-header">
<h5 class="chrono-card-title text-danger">
<i class="bi bi-tools text-danger"></i>
Database Maintenance & Tools
</h5>
</div>
<div class="d-flex flex-wrap gap-3">
<button class="btn btn-chrono-secondary" @onclick="SeedDataAsync" disabled="@isActionBusy">
<i class="bi bi-box-arrow-in-down me-1"></i> Seed Realistic Sample Data
</button>
<button class="btn btn-chrono-danger" @onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<i class="bi bi-trash3-fill me-1"></i> Wipe / Reset All Tables
</button>
</div>
</div>
@code {
private DatabaseHealthStatus? health;
private MigrationReport? migrationReport;
private DatabaseProviderType selectedProvider;
private string connectionStringInput = "";
private string inMemoryNameInput = "";
private bool isSaving;
private bool isActionBusy;
private string? alertMessage;
private bool alertIsSuccess;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
selectedProvider = ConfigService.ProviderType;
connectionStringInput = ConfigService.ConnectionString;
inMemoryNameInput = ConfigService.InMemoryDbName;
await RefreshAsync();
}
private void HandleConfigChanged()
{
selectedProvider = ConfigService.ProviderType;
connectionStringInput = ConfigService.ConnectionString;
inMemoryNameInput = ConfigService.InMemoryDbName;
_ = RefreshAsync();
}
private async Task RefreshAsync()
{
health = await DbService.CheckHealthAsync();
migrationReport = await DbService.GetMigrationStatusAsync();
}
private void SelectProvider(DatabaseProviderType provider)
{
selectedProvider = provider;
}
private void SetPreset(string conn)
{
connectionStringInput = conn;
}
private async Task SaveConfigurationAsync()
{
isSaving = true;
ConfigService.SetConnectionString(connectionStringInput);
ConfigService.SetInMemoryDbName(inMemoryNameInput);
ConfigService.SetProvider(selectedProvider);
if (selectedProvider == DatabaseProviderType.InMemory)
{
await DbService.EnsureDatabaseCreatedAsync();
}
await RefreshAsync();
isSaving = false;
alertIsSuccess = health?.IsConnected == true;
alertMessage = health?.IsConnected == true
? "Configuration updated successfully and connection verified."
: $"Configuration updated, but connection failed: {health?.ErrorMessage}";
}
private async Task ApplyMigrationsAsync()
{
isActionBusy = true;
var result = await DbService.ApplyMigrationsAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task SeedDataAsync()
{
isActionBusy = true;
var result = await DbService.SeedSampleDataAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task ResetDatabaseAsync()
{
isActionBusy = true;
var result = await DbService.ResetDatabaseAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
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>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,313 @@
@page "/"
@implements IDisposable
<PageTitle>Chrono DB Hub - Dashboard</PageTitle>
<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>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync" disabled="@isLoading">
<i class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></i> Refresh Status
</button>
<a href="test-suite" class="btn btn-chrono-primary">
<i class="bi bi-play-circle-fill"></i> Run All Tests
</a>
</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>
}
<!-- Database Health & Connection Banner -->
@if (health != null)
{
@if (!health.IsConnected)
{
<div class="chrono-card border-danger mb-4" style="background-color: rgba(248, 81, 73, 0.08);">
<div class="d-flex align-items-start justify-content-between">
<div class="d-flex gap-3">
<i class="bi bi-exclamation-octagon-fill text-danger fs-2"></i>
<div>
<h5 class="text-danger fw-bold mb-1">PostgreSQL Connection Offline</h5>
<p class="text-secondary small mb-2">
@(health.ErrorMessage ?? "Could not connect to the database server. Ensure PostgreSQL is running on the configured host/port.")
</p>
<div class="d-flex gap-2 mt-2">
<button class="btn btn-sm btn-chrono-primary" @onclick="SwitchToInMemoryAsync">
<i class="bi bi-lightning-charge-fill"></i> Switch to In-Memory DB (Instant Testing)
</button>
<a href="diagnostics" class="btn btn-sm btn-chrono-secondary">
<i class="bi bi-gear-fill"></i> Configure Connection
</a>
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshAsync">
<i class="bi bi-arrow-repeat"></i> Retry Connection
</button>
</div>
</div>
</div>
</div>
</div>
}
else if (health.ProviderType == DatabaseProviderType.InMemory)
{
<div class="chrono-card border-info mb-4" style="background-color: rgba(57, 197, 207, 0.08);">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-cpu-fill text-info fs-2"></i>
<div>
<h6 class="text-info fw-bold mb-0">In-Memory Database Active (@health.ConnectionString)</h6>
<span class="text-secondary small">Running zero-config in-memory tests. Full CRUD and test suites are active.</span>
</div>
</div>
<button class="btn btn-sm btn-chrono-secondary" @onclick="SwitchToPostgresAsync">
<i class="bi bi-database"></i> Switch to PostgreSQL
</button>
</div>
</div>
}
}
<!-- Metrics Row -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<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-label">Card Notes</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<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-label">User Decks</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<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-label">Passkeys</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<a href="diagnostics" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
<i class="bi bi-arrow-left-right"></i>
</div>
<div>
<div class="metric-value">
@(health?.ProviderType == DatabaseProviderType.PostgreSQL
? $"{health.AppliedMigrationsCount} / {health.AppliedMigrationsCount + health.PendingMigrationsCount}"
: "N/A")
</div>
<div class="metric-label">Applied Migrations</div>
</div>
</div>
</a>
</div>
</div>
<!-- Quick Actions & Features -->
<div class="row g-4">
<div class="col-lg-7">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-stars text-primary"></i>
Cloud Database Features Under Test
</h5>
<span class="badge bg-secondary">Cloud.AppDbContext</span>
</div>
<div class="d-flex flex-column gap-3">
<div class="p-3 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-bold text-white"><i class="bi bi-card-text text-primary me-2"></i> Card Notes Entity (`CardNote`)</span>
<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.
</p>
</div>
<div class="p-3 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-bold text-white"><i class="bi bi-collection-play text-success me-2"></i> User Decks & JSONB (`UserDeck`)</span>
<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.
</p>
</div>
<div class="p-3 rounded bg-dark border border-secondary border-opacity-25">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-bold text-white"><i class="bi bi-key-fill text-purple me-2"></i> Passkey Credentials (`PasskeyCredential`)</span>
<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.
</p>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<div class="chrono-card h-100">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-wrench-adjustable text-warning"></i>
Quick Action Hub
</h5>
</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">
<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">
<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">
<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">
<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">
<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">
<span><i class="bi bi-trash3-fill me-2"></i> Wipe & Reset Database</span>
<i class="bi bi-exclamation-triangle"></i>
</button>
</div>
</div>
</div>
</div>
@code {
private DatabaseHealthStatus? health;
private bool isLoading;
private bool isActionBusy;
private string? alertMessage;
private bool alertIsSuccess;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await RefreshAsync();
}
private async Task RefreshAsync()
{
isLoading = true;
health = await DbService.CheckHealthAsync();
isLoading = false;
}
private void HandleConfigChanged()
{
_ = RefreshAsync();
}
private async Task SwitchToInMemoryAsync()
{
ConfigService.SetProvider(DatabaseProviderType.InMemory);
var result = await DbService.EnsureDatabaseCreatedAsync();
alertIsSuccess = result.Success;
alertMessage = "Switched to In-Memory Test Database. " + result.Message;
await RefreshAsync();
}
private async Task SwitchToPostgresAsync()
{
ConfigService.SetProvider(DatabaseProviderType.PostgreSQL);
alertIsSuccess = true;
alertMessage = "Switched provider to PostgreSQL.";
await RefreshAsync();
}
private async Task SeedDataAsync()
{
isActionBusy = true;
var result = await DbService.SeedSampleDataAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task ApplyMigrationsAsync()
{
isActionBusy = true;
var result = await DbService.ApplyMigrationsAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
private async Task ResetDatabaseAsync()
{
isActionBusy = true;
var result = await DbService.ResetDatabaseAsync();
isActionBusy = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
await RefreshAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
@@ -0,0 +1,509 @@
@page "/passkeys"
@implements IDisposable
@using System.Security.Cryptography
<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>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadPasskeysAsync">
<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 Passkey
</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 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>
}
</div>
<div class="text-secondary small">
Showing @passkeys.Count credential(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 passkeys from database...</div>
</div>
}
else if (!passkeys.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
<h5>No Passkeys Found</h5>
<p class="small">No passkey credentials were found 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 Passkey
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSamplePasskeysAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Passkeys
</button>
</div>
</div>
}
else
{
<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>
</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>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 700px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? $"Edit Passkey #{currentPasskey.Id}" : "Add New Passkey")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
@if (!isEditing)
{
<div class="mb-3 d-flex justify-content-end">
<button type="button" class="btn btn-sm btn-chrono-secondary" @onclick="GenerateRandomPasskey">
<i class="bi bi-dice-5-fill text-warning me-1"></i> Generate Random Test Key
</button>
</div>
}
<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" />
</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" />
</div>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">CREDENTIAL ID (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(credIdHex) bytes</span>
</div>
<textarea class="form-control form-control-chrono font-monospace" rows="2"
placeholder="e.g. AABBCCDD01020304"
@bind="credIdHex"></textarea>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">PUBLIC KEY (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(pubKeyHex) bytes</span>
</div>
<textarea class="form-control form-control-chrono font-monospace" rows="2"
placeholder="e.g. 3059301306072A8648CE3D020106082A8648CE3D030107"
@bind="pubKeyHex"></textarea>
</div>
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label text-secondary small fw-bold mb-0">USER HANDLE (HEX STRING)</label>
<span class="text-secondary small font-monospace">@GetByteCount(userHandleHex) bytes</span>
</div>
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. DEADBEAF01020304"
@bind="userHandleHex" />
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SavePasskeyAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Passkey")
</button>
</div>
</div>
</div>
}
<!-- View Binary Details Modal -->
@if (showViewModal && viewedPasskey != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 650px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi bi-shield-check text-purple me-2"></i>
Passkey Binary Inspection (#@viewedPasskey.Id)
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showViewModal = false"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">CREDENTIAL ID</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.CredentialId)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.CredentialId)</pre>
</div>
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">PUBLIC KEY</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.PublicKey)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.PublicKey)</pre>
</div>
<div class="mb-3">
<label class="text-secondary small fw-bold mb-1">USER HANDLE</label>
<div class="text-secondary small mb-1">HEX:</div>
<pre class="code-block mb-2">@Convert.ToHexString(viewedPasskey.UserHandle)</pre>
<div class="text-secondary small mb-1">BASE64:</div>
<pre class="code-block mb-0">@Convert.ToBase64String(viewedPasskey.UserHandle)</pre>
</div>
<div class="row g-2">
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary">
<span class="text-secondary small d-block">AAGUID</span>
<span class="text-white font-monospace small">@viewedPasskey.AaGuid</span>
</div>
</div>
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary">
<span class="text-secondary small d-block">SIGN COUNT</span>
<span class="text-purple font-monospace small fw-bold">@viewedPasskey.SignCount</span>
</div>
</div>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && passkeyToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<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>
</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>
</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-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Passkey")
</button>
</div>
</div>
</div>
}
@code {
private List<PasskeyCredential> passkeys = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
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 = "";
private bool showViewModal;
private PasskeyCredential? viewedPasskey;
private bool showDeleteModal;
private PasskeyCredential? passkeyToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await LoadPasskeysAsync();
}
private void HandleConfigChanged()
{
_ = LoadPasskeysAsync();
}
private async Task LoadPasskeysAsync()
{
isLoading = true;
try
{
passkeys = await DbService.GetPasskeysAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading passkeys: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterPasskeys()
{
passkeys = await DbService.GetPasskeysAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadPasskeysAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentPasskey = new PasskeyCredential
{
AaGuid = Guid.NewGuid().ToString(),
SignCount = 0,
CredentialId = [],
PublicKey = [],
UserHandle = []
};
GenerateRandomPasskey();
showModal = true;
}
private void GenerateRandomPasskey()
{
var cred = new byte[16];
var pub = new byte[32];
var user = new byte[8];
RandomNumberGenerator.Fill(cred);
RandomNumberGenerator.Fill(pub);
RandomNumberGenerator.Fill(user);
credIdHex = Convert.ToHexString(cred);
pubKeyHex = Convert.ToHexString(pub);
userHandleHex = Convert.ToHexString(user);
currentPasskey.AaGuid = Guid.NewGuid().ToString();
currentPasskey.SignCount = Random.Shared.Next(1, 100);
}
private void OpenEditModal(PasskeyCredential passkey)
{
isEditing = true;
currentPasskey = new PasskeyCredential
{
Id = passkey.Id,
AaGuid = passkey.AaGuid,
SignCount = passkey.SignCount,
CredentialId = passkey.CredentialId,
PublicKey = passkey.PublicKey,
UserHandle = passkey.UserHandle
};
credIdHex = Convert.ToHexString(passkey.CredentialId);
pubKeyHex = Convert.ToHexString(passkey.PublicKey);
userHandleHex = Convert.ToHexString(passkey.UserHandle);
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private static int GetByteCount(string hex)
{
var clean = hex.Replace(" ", "").Replace("-", "");
return clean.Length / 2;
}
private async Task SavePasskeyAsync()
{
try
{
var cleanCred = credIdHex.Replace(" ", "").Replace("-", "");
var cleanPub = pubKeyHex.Replace(" ", "").Replace("-", "");
var cleanUser = userHandleHex.Replace(" ", "").Replace("-", "");
if (string.IsNullOrWhiteSpace(cleanCred) || cleanCred.Length % 2 != 0)
{
alertIsSuccess = false;
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;
alertMessage = "Invalid User Handle hex string (must be even length).";
return;
}
currentPasskey.CredentialId = Convert.FromHexString(cleanCred);
currentPasskey.PublicKey = Convert.FromHexString(cleanPub);
currentPasskey.UserHandle = Convert.FromHexString(cleanUser);
isSaving = true;
var result = await DbService.SavePasskeyAsync(currentPasskey, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadPasskeysAsync();
}
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Failed to parse hex or save passkey: " + ex.Message;
}
}
private void OpenViewModal(PasskeyCredential passkey)
{
viewedPasskey = passkey;
showViewModal = true;
}
private void OpenDeleteModal(PasskeyCredential passkey)
{
passkeyToDelete = passkey;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (passkeyToDelete == null) return;
isSaving = true;
var result = await DbService.DeletePasskeyAsync(passkeyToDelete.Id);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
passkeyToDelete = null;
await LoadPasskeysAsync();
}
private async Task SeedSamplePasskeysAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadPasskeysAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,263 @@
@page "/test-suite"
@implements IDisposable
<PageTitle>Database Test Suite Runner - Chrono DB</PageTitle>
<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>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="ClearResults" disabled="@isRunning">
<i class="bi bi-eraser-fill"></i> Clear Results
</button>
<button class="btn btn-chrono-primary" @onclick="RunTestsAsync" disabled="@isRunning">
<i class="bi bi-play-circle-fill @(isRunning ? "spin" : "")"></i>
@(isRunning ? "Running Test Suite..." : "Run All Database Tests")
</button>
</div>
</div>
<!-- Scorecard Summary -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box metric-icon-blue">
<i class="bi bi-list-check"></i>
</div>
<div>
<div class="metric-value">@testResults.Count</div>
<div class="metric-label">Total Tests</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box metric-icon-green">
<i class="bi bi-check-circle-fill"></i>
</div>
<div>
<div class="metric-value text-success">@testResults.Count(t => t.Status == TestStatus.Passed)</div>
<div class="metric-label">Passed</div>
</div>
</div>
</div>
<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")">
<i class="bi @(testResults.Any(t => t.Status == TestStatus.Failed) ? "bi-x-circle-fill text-danger" : "bi-shield-check")"></i>
</div>
<div>
<div class="metric-value @(testResults.Any(t => t.Status == TestStatus.Failed) ? "text-danger" : "")">
@testResults.Count(t => t.Status == TestStatus.Failed)
</div>
<div class="metric-label">Failed</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
<i class="bi bi-stopwatch-fill"></i>
</div>
<div>
<div class="metric-value">@(testResults.Sum(t => t.DurationMs))ms</div>
<div class="metric-label">Total Duration</div>
</div>
</div>
</div>
</div>
@if (isRunning)
{
<div class="chrono-card mb-4 border-primary" style="background-color: rgba(88, 166, 255, 0.08);">
<div class="d-flex align-items-center gap-3">
<div class="spinner-border text-primary" role="status"></div>
<div>
<h6 class="text-primary fw-bold mb-0">Executing Test Suite...</h6>
<span class="text-secondary small">@currentProgressStatus</span>
</div>
</div>
</div>
}
<!-- Test Cases List -->
<div class="chrono-card">
<div class="chrono-card-header">
<h5 class="chrono-card-title">
<i class="bi bi-card-checklist text-primary"></i>
Test Suite Execution Results
</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")">
@ConfigService.ProviderType
</span>
</div>
</div>
@if (!testResults.Any() && !isRunning)
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-play-circle fs-1 d-block mb-2"></i>
<h5>Test Suite Ready</h5>
<p class="small">Click "Run All Database Tests" above to execute tests against the database.</p>
<button class="btn btn-chrono-primary mt-2" @onclick="RunTestsAsync">
<i class="bi bi-play-circle-fill"></i> Start Test Run
</button>
</div>
}
else
{
<div class="d-flex flex-column gap-3">
@foreach (var test in testResults)
{
<div class="p-3 rounded bg-dark border @GetBorderClass(test.Status)">
<div class="d-flex align-items-center justify-content-between mb-2">
<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>
</div>
<div class="d-flex align-items-center gap-3">
<span class="text-secondary small font-monospace">@(test.DurationMs)ms</span>
<span class="badge @GetBadgeClass(test.Status) px-3 py-2">
@if (test.Status == TestStatus.Running)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
else if (test.Status == TestStatus.Passed)
{
<i class="bi bi-check-lg me-1"></i>
}
else if (test.Status == TestStatus.Failed)
{
<i class="bi bi-x-lg me-1"></i>
}
@test.Status.ToString().ToUpper()
</span>
</div>
</div>
<p class="text-secondary small mb-2">@test.Description</p>
@if (!string.IsNullOrEmpty(test.ErrorMessage))
{
<div class="alert alert-danger p-2 mb-2 small">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Failure:</strong> @test.ErrorMessage
</div>
}
<!-- Expandable Logs -->
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary py-0 px-2" style="font-size: 0.78rem;"
@onclick="() => ToggleLogs(test.Id)">
<i class="bi @(expandedTestLogs.Contains(test.Id) ? "bi-chevron-up" : "bi-chevron-down") me-1"></i>
@(expandedTestLogs.Contains(test.Id) ? "Hide Log Details" : $"View Logs ({test.Logs.Count} events)")
</button>
@if (expandedTestLogs.Contains(test.Id))
{
<div class="log-console mt-2">
@if (test.Logs.Any())
{
@foreach (var log in test.Logs)
{
<div>@log</div>
}
}
else
{
<div class="text-secondary fst-italic">No logs recorded.</div>
}
</div>
}
</div>
</div>
}
</div>
}
</div>
@code {
private List<DatabaseTestCaseResult> testResults = [];
private HashSet<string> expandedTestLogs = [];
private bool isRunning;
private string currentProgressStatus = "";
protected override void OnInitialized()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
}
private void HandleConfigChanged()
{
ClearResults();
InvokeAsync(StateHasChanged);
}
private async Task RunTestsAsync()
{
isRunning = true;
testResults.Clear();
expandedTestLogs.Clear();
currentProgressStatus = "Initializing test suite...";
StateHasChanged();
testResults = await DbService.RunAllTestsAsync(msg =>
{
currentProgressStatus = msg;
InvokeAsync(StateHasChanged);
});
// Automatically expand any failed tests for convenience
foreach (var failed in testResults.Where(t => t.Status == TestStatus.Failed))
{
expandedTestLogs.Add(failed.Id);
}
isRunning = false;
StateHasChanged();
}
private void ClearResults()
{
testResults.Clear();
expandedTestLogs.Clear();
}
private void ToggleLogs(string testId)
{
if (expandedTestLogs.Contains(testId))
expandedTestLogs.Remove(testId);
else
expandedTestLogs.Add(testId);
}
private static string GetBorderClass(TestStatus status) => 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
{
TestStatus.Passed => "bg-success",
TestStatus.Failed => "bg-danger",
TestStatus.Running => "bg-primary",
_ => "bg-secondary"
};
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,604 @@
@page "/user-decks"
@implements IDisposable
@using System.Text.Json
<PageTitle>User Decks 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">User Decks Database (`UserDeck`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete deck builds with native JSONB collection mappings.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadDecksAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Create New Deck
</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 decks by name, season, or notes..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterDecks" />
@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 @decks.Count deck(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 decks from database...</div>
</div>
}
else if (!decks.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-collection fs-1 d-block mb-2"></i>
<h5>No User Decks Found</h5>
<p class="small">No decks were found matching your search.</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> Create First Deck
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedSampleDecksAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Decks
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th>Deck Name</th>
<th style="width: 120px;">Season</th>
<th style="width: 130px;">Cards (JSONB)</th>
<th style="width: 110px;">Divers</th>
<th>Notes</th>
<th style="width: 150px;">Updated (UTC)</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var deck in decks)
{
var totalCards = deck.Cards?.Count ?? 0;
var uniqueCards = deck.Cards?.Distinct().Count() ?? 0;
var diverCount = deck.Divers?.Count ?? 0;
<tr>
<td>
<div class="fw-bold text-white">@deck.Name</div>
<div class="text-secondary" style="font-size: 0.75rem;">ID: @deck.Id</div>
</td>
<td>
<span class="badge bg-secondary">@(string.IsNullOrEmpty(deck.Season) ? "General" : deck.Season)</span>
</td>
<td>
<span class="badge bg-primary" title="@($"{uniqueCards} unique card type(s)")">
<i class="bi bi-stack me-1"></i> @totalCards cards
</span>
</td>
<td>
<span class="badge @(diverCount > 0 ? "bg-purple" : "bg-dark border border-secondary text-secondary")">
<i class="bi bi-person-fill me-1"></i> @diverCount
</span>
</td>
<td>
<div class="text-secondary text-truncate" style="max-width: 220px;" title="@deck.Notes">
@(string.IsNullOrEmpty(deck.Notes) ? "-" : deck.Notes)
</div>
</td>
<td>
<div class="small text-secondary">
@deck.UpdatedAt.ToString("yyyy-MM-dd HH:mm")
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(deck)" title="View JSON & Breakdown">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(deck)" title="Edit Deck">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(deck)" title="Delete Deck">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Add / Edit Modal -->
@if (showModal)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 750px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isEditing ? "bi-pencil-square" : "bi-plus-circle") text-primary me-2"></i>
@(isEditing ? "Edit User Deck" : "Create New User Deck")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label text-secondary small fw-bold">DECK NAME</label>
<input type="text" class="form-control form-control-chrono" placeholder="Enter deck name..."
@bind="currentDeck.Name" />
</div>
<div class="col-md-4">
<label class="form-label text-secondary small fw-bold">SEASON</label>
<input type="text" class="form-control form-control-chrono" placeholder="e.g. Season 1, Tournament"
@bind="currentDeck.Season" />
</div>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">DECK STRATEGY & NOTES</label>
<textarea class="form-control form-control-chrono" rows="2"
placeholder="Notes about curve, combos, sideboarding..."
@bind="currentDeck.Notes"></textarea>
</div>
<!-- Cards (JSONB Collection) Section -->
<div class="chrono-card mb-3 p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="fw-bold text-white small">
<i class="bi bi-stack text-primary me-1"></i>
DECK CARDS (@(currentDeck.Cards?.Count ?? 0) Total)
</span>
</div>
<!-- Add Card Form -->
<div class="row g-2 mb-3">
<div class="col-md-7">
<input type="text" class="form-control form-control-chrono form-control-sm"
list="deckCardSuggestions" placeholder="Card name..." @bind="newCardName" />
<datalist id="deckCardSuggestions">
@foreach (var c in allCardNames.Take(50))
{
<option value="@c" />
}
</datalist>
</div>
<div class="col-md-2">
<select class="form-select form-select-chrono form-select-sm" @bind="newCardCount">
<option value="1">x1</option>
<option value="2">x2</option>
<option value="3">x3</option>
</select>
</div>
<div class="col-md-3">
<button type="button" class="btn btn-sm btn-chrono-secondary w-100" @onclick="AddCardToDeck">
<i class="bi bi-plus-circle"></i> Add Card
</button>
</div>
</div>
<!-- Current Cards List (Grouped for display) -->
@if (currentDeck.Cards == null || !currentDeck.Cards.Any())
{
<div class="text-secondary small fst-italic p-2 bg-dark rounded border border-secondary border-opacity-25">
No cards added yet. Use the selector above to add cards to this deck.
</div>
}
else
{
<div class="d-flex flex-wrap gap-2" style="max-height: 160px; overflow-y: auto;">
@foreach (var group in currentDeck.Cards.GroupBy(c => c))
{
var cardName = group.Key;
var count = group.Count();
<div class="badge bg-dark border border-secondary p-2 d-flex align-items-center gap-2">
<span class="text-white">@cardName</span>
<span class="badge bg-primary">x@count</span>
<button type="button" class="btn btn-sm p-0 text-secondary" title="Remove one" @onclick="() => RemoveOneCardFromDeck(cardName)">
<i class="bi bi-dash"></i>
</button>
<button type="button" class="btn btn-sm p-0 text-danger" title="Remove all" @onclick="() => RemoveAllCardsFromDeck(cardName)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
}
</div>
}
</div>
<!-- Divers (JSONB Collection) Section -->
<div class="chrono-card mb-0 p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="fw-bold text-white small">
<i class="bi bi-person-fill text-purple me-1"></i>
DIVERS / AGENTS (@(currentDeck.Divers?.Count ?? 0))
</span>
</div>
<div class="row g-2 mb-3">
<div class="col-md-9">
<input type="text" class="form-control form-control-chrono form-control-sm"
list="diverSuggestions" placeholder="Agent / Diver name..." @bind="newDiverName" />
<datalist id="diverSuggestions">
@foreach (var c in agentCardNames.Take(40))
{
<option value="@c" />
}
</datalist>
</div>
<div class="col-md-3">
<button type="button" class="btn btn-sm btn-chrono-secondary w-100" @onclick="AddDiverToDeck">
<i class="bi bi-plus-circle"></i> Add Diver
</button>
</div>
</div>
@if (currentDeck.Divers == null || !currentDeck.Divers.Any())
{
<div class="text-secondary small fst-italic p-2 bg-dark rounded border border-secondary border-opacity-25">
No divers specified for this deck.
</div>
}
else
{
<div class="d-flex flex-wrap gap-2">
@foreach (var diver in currentDeck.Divers)
{
<div class="badge bg-dark border border-purple p-2 d-flex align-items-center gap-2">
<span class="text-purple">@diver</span>
<button type="button" class="btn btn-sm p-0 text-danger" @onclick="() => RemoveDiverFromDeck(diver)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
}
</div>
}
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveDeckAsync" disabled="@isSaving">
@(isSaving ? "Saving..." : "Save Deck")
</button>
</div>
</div>
</div>
}
<!-- View JSON & Breakdown Modal -->
@if (showViewModal && viewedDeck != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 650px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi bi-file-earmark-code text-primary me-2"></i>
Deck Inspection: @viewedDeck.Name
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showViewModal = false"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<h6 class="text-secondary small fw-bold mb-2">RAW JSON DATABASE RECORD</h6>
<pre class="code-block" style="max-height: 250px;">@GetFormattedDeckJson(viewedDeck)</pre>
</div>
<div class="mb-2">
<h6 class="text-secondary small fw-bold mb-2">CARDS BREAKDOWN</h6>
<div class="row g-2">
@foreach (var group in (viewedDeck.Cards ?? []).GroupBy(c => c))
{
<div class="col-md-6">
<div class="p-2 rounded bg-dark border border-secondary d-flex align-items-center justify-content-between">
<span class="text-white small">@group.Key</span>
<span class="badge bg-primary">x@group.Count()</span>
</div>
</div>
}
</div>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
</div>
</div>
</div>
}
<!-- Delete Confirmation Modal -->
@if (showDeleteModal && deckToDelete != null)
{
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Deck
</h5>
<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 deck:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3">
@deckToDelete.Name
</div>
<p class="text-secondary small mb-0">This will remove the deck record and its JSONB associations 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-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Deck")
</button>
</div>
</div>
</div>
}
@code {
private List<UserDeck> decks = [];
private List<string> allCardNames = [];
private List<string> agentCardNames = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private UserDeck currentDeck = new();
private string newCardName = "";
private int newCardCount = 1;
private string newDiverName = "";
private bool showViewModal;
private UserDeck? viewedDeck;
private bool showDeleteModal;
private UserDeck? deckToDelete;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
try
{
allCardNames = CardDatabase.Cards
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
agentCardNames = CardDatabase.Cards
.Where(c => c.IsAgent)
.Select(c => c.Name)
.Where(n => !string.IsNullOrWhiteSpace(n))
.Distinct()
.OrderBy(n => n)
.ToList();
}
catch
{
allCardNames = [];
agentCardNames = [];
}
await LoadDecksAsync();
}
private void HandleConfigChanged()
{
_ = LoadDecksAsync();
}
private async Task LoadDecksAsync()
{
isLoading = true;
try
{
decks = await DbService.GetUserDecksAsync(searchTerm);
}
catch (Exception ex)
{
alertIsSuccess = false;
alertMessage = "Error loading user decks: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task FilterDecks()
{
decks = await DbService.GetUserDecksAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = "";
await LoadDecksAsync();
}
private void OpenAddModal()
{
isEditing = false;
currentDeck = new UserDeck
{
Id = Guid.NewGuid(),
Name = "",
Season = "Season 1",
UpdatedAt = DateTime.UtcNow,
Cards = new List<string>(),
Divers = new List<string>()
};
newCardName = "";
newCardCount = 1;
newDiverName = "";
showModal = true;
}
private void OpenEditModal(UserDeck deck)
{
isEditing = true;
currentDeck = new UserDeck
{
Id = deck.Id,
Name = deck.Name,
Season = deck.Season,
Notes = deck.Notes,
UpdatedAt = deck.UpdatedAt,
Cards = deck.Cards?.ToList() ?? new(),
Divers = deck.Divers?.ToList() ?? new()
};
newCardName = "";
newCardCount = 1;
newDiverName = "";
showModal = true;
}
private void CloseModal()
{
showModal = false;
}
private void AddCardToDeck()
{
if (string.IsNullOrWhiteSpace(newCardName)) return;
currentDeck.Cards ??= new List<string>();
for (int i = 0; i < newCardCount; i++)
{
currentDeck.Cards.Add(newCardName.Trim());
}
newCardName = "";
newCardCount = 1;
}
private void RemoveOneCardFromDeck(string cardName)
{
currentDeck.Cards?.Remove(cardName);
}
private void RemoveAllCardsFromDeck(string cardName)
{
currentDeck.Cards?.RemoveAll(c => c == cardName);
}
private void AddDiverToDeck()
{
if (string.IsNullOrWhiteSpace(newDiverName)) return;
currentDeck.Divers ??= new List<string>();
if (!currentDeck.Divers.Contains(newDiverName.Trim(), StringComparer.OrdinalIgnoreCase))
{
currentDeck.Divers.Add(newDiverName.Trim());
}
newDiverName = "";
}
private void RemoveDiverFromDeck(string diver)
{
currentDeck.Divers?.Remove(diver);
}
private async Task SaveDeckAsync()
{
if (string.IsNullOrWhiteSpace(currentDeck.Name))
{
alertIsSuccess = false;
alertMessage = "Deck name cannot be empty.";
return;
}
isSaving = true;
var result = await DbService.SaveUserDeckAsync(currentDeck, !isEditing);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
if (result.Success)
{
showModal = false;
await LoadDecksAsync();
}
}
private void OpenViewModal(UserDeck deck)
{
viewedDeck = deck;
showViewModal = true;
}
private string GetFormattedDeckJson(UserDeck deck)
{
return JsonSerializer.Serialize(deck, new JsonSerializerOptions { WriteIndented = true });
}
private void OpenDeleteModal(UserDeck deck)
{
deckToDelete = deck;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (deckToDelete == null) return;
isSaving = true;
var result = await DbService.DeleteUserDeckAsync(deckToDelete.Id);
isSaving = false;
alertIsSuccess = result.Success;
alertMessage = result.Message;
showDeleteModal = false;
deckToDelete = null;
await LoadDecksAsync();
}
private async Task SeedSampleDecksAsync()
{
var result = await DbService.SeedSampleDataAsync();
alertIsSuccess = result.Success;
alertMessage = result.Message;
await LoadDecksAsync();
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -0,0 +1,67 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p>
<em>Loading...</em>
</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate streaming rendering
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}