Test
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user