Test
This commit is contained in:
@@ -4,7 +4,7 @@ using Model;
|
|||||||
|
|
||||||
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||||
var docsDir = Path.Combine(repoRoot, "chrono.docs");
|
var docsDir = Path.Combine(repoRoot, "chrono.docs");
|
||||||
var webWwwRoot = Path.Combine(repoRoot, "Chrono", "Standalone", "wwwroot");
|
var webWwwRoot = Path.Combine(repoRoot, "Chrono", "Web", "wwwroot");
|
||||||
var generatedFile = Path.Combine(repoRoot, "Chrono", "Shared", "Generated", "Cards.g.cs");
|
var generatedFile = Path.Combine(repoRoot, "Chrono", "Shared", "Generated", "Cards.g.cs");
|
||||||
|
|
||||||
Console.WriteLine($"Repo root: {repoRoot}");
|
Console.WriteLine($"Repo root: {repoRoot}");
|
||||||
|
|||||||
+27
-27
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Standalone", "Standalone\Standalone.csproj", "{18981096-443A-44BF-AE56-6499C2B03AEF}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "Web\Web.csproj", "{80847188-4B2E-40C9-BCD3-2562259E60DE}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "Build\Build.csproj", "{36E3775C-0E28-4EAE-AE92-4FB493E3787F}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "Build\Build.csproj", "{36E3775C-0E28-4EAE-AE92-4FB493E3787F}"
|
||||||
EndProject
|
EndProject
|
||||||
@@ -10,8 +10,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Deploy", "Deploy\Deploy.csp
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{90F32056-6983-4224-8A8C-E797C71633F3}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{90F32056-6983-4224-8A8C-E797C71633F3}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cloud", "Cloud\Cloud.csproj", "{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Shared\Shared.csproj", "{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared", "Shared\Shared.csproj", "{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdown", "Markdown\Markdown.csproj", "{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdown", "Markdown\Markdown.csproj", "{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}"
|
||||||
@@ -20,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "md", "md", "{A4C572E4-E709-
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shared", "shared", "{92ED656F-1D94-4348-97AD-B76094B14A8E}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shared", "shared", "{92ED656F-1D94-4348-97AD-B76094B14A8E}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DatabaseTest", "DatabaseTest\DatabaseTest.csproj", "{2B162358-3332-448C-B0D6-776F9CAAD398}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -30,18 +30,6 @@ Global
|
|||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{18981096-443A-44BF-AE56-6499C2B03AEF}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{36E3775C-0E28-4EAE-AE92-4FB493E3787F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{36E3775C-0E28-4EAE-AE92-4FB493E3787F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{36E3775C-0E28-4EAE-AE92-4FB493E3787F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{36E3775C-0E28-4EAE-AE92-4FB493E3787F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{36E3775C-0E28-4EAE-AE92-4FB493E3787F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{36E3775C-0E28-4EAE-AE92-4FB493E3787F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -90,18 +78,6 @@ Global
|
|||||||
{90F32056-6983-4224-8A8C-E797C71633F3}.Release|x64.Build.0 = Release|Any CPU
|
{90F32056-6983-4224-8A8C-E797C71633F3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{90F32056-6983-4224-8A8C-E797C71633F3}.Release|x86.ActiveCfg = Release|Any CPU
|
{90F32056-6983-4224-8A8C-E797C71633F3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{90F32056-6983-4224-8A8C-E797C71633F3}.Release|x86.Build.0 = Release|Any CPU
|
{90F32056-6983-4224-8A8C-E797C71633F3}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{BFB7B73F-EFDD-4DE8-89F2-E1CBE1B55C27}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{30D8468D-01E1-4D8A-99AF-EE4A75A5E956}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
@@ -126,6 +102,30 @@ Global
|
|||||||
{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}.Release|x64.Build.0 = Release|Any CPU
|
{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}.Release|x86.ActiveCfg = Release|Any CPU
|
{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}.Release|x86.Build.0 = Release|Any CPU
|
{BA7A908C-FEF7-4AC7-ABCE-437259A6C34C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{2B162358-3332-448C-B0D6-776F9CAAD398}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{80847188-4B2E-40C9-BCD3-2562259E60DE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Shared\Shared.csproj"/>
|
|
||||||
<ProjectReference Include="..\Model\Model.csproj"/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Fido2" Version="4.0.1"/>
|
|
||||||
<PackageReference Include="Fido2.AspNet" Version="4.0.1"/>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="MudBlazor" Version="9.6.0"/>
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2"/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<AdditionalFiles Include="Components\Layout\MainLayout.razor"/>
|
|
||||||
<AdditionalFiles Include="Components\Layout\NavMenu.razor"/>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<RootNamespace>Cloud</RootNamespace>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
||||||
<title>Chrono CCG</title>
|
|
||||||
<base href="/"/>
|
|
||||||
<link href="https://fonts.googleapis.com" rel="preconnect"/>
|
|
||||||
<link crossorigin href="https://fonts.gstatic.com" rel="preconnect"/>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
|
||||||
<link href="/lib/bootstrap-icons/bootstrap-icons.min.css" rel="stylesheet"/>
|
|
||||||
<link href="/lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet"/>
|
|
||||||
<link href="/_content/Telerik.UI.for.Blazor/css/kendo-theme-bootstrap/all.css" rel="stylesheet"/>
|
|
||||||
<link href="/css/app.css" rel="stylesheet"/>
|
|
||||||
<link href="/Cloud.styles.css" rel="stylesheet"/>
|
|
||||||
<script defer src="/_content/Telerik.UI.for.Blazor/js/telerik-blazor.js"></script>
|
|
||||||
<script src="/js/passkey.js"></script>
|
|
||||||
<link href="/favicon.png" rel="icon" type="image/png"/>
|
|
||||||
<HeadOutlet/>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<CascadingAuthenticationState>
|
|
||||||
<Routes/>
|
|
||||||
</CascadingAuthenticationState>
|
|
||||||
<script src="_framework/blazor.web.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
@using System.Text.RegularExpressions
|
|
||||||
@namespace Chrono.Components
|
|
||||||
|
|
||||||
@if (Card != null)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop" @onclick="HandleBackdropClick"></div>
|
|
||||||
<div class="card-detail @(!HasImage ? "card-detail-noimg" : "")">
|
|
||||||
<button class="detail-close" @onclick="HandleClose"><i class="bi bi-x-lg"></i></button>
|
|
||||||
<div class="detail-layout @(!HasImage ? "layout-noimg" : "")">
|
|
||||||
@if (HasImage)
|
|
||||||
{
|
|
||||||
<div class="detail-image">
|
|
||||||
<img src="@Card.ImagePath" alt="@Card.Name"
|
|
||||||
onerror="this.src='@Card.SmallImagePath';this.onerror=null;"/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="detail-info">
|
|
||||||
<div class="detail-header">
|
|
||||||
<h2>@Card.Name</h2>
|
|
||||||
<div class="detail-meta">
|
|
||||||
<span class="meta-badge category @Card.Category?.ToLowerInvariant()">@Card.Category</span>
|
|
||||||
@if (Card.Cost.HasValue)
|
|
||||||
{
|
|
||||||
<span class="meta-badge cost"><i class="bi bi-lightning-fill"></i> @Card.Cost</span>
|
|
||||||
}
|
|
||||||
@if (Card.Attack.HasValue)
|
|
||||||
{
|
|
||||||
<span class="meta-badge attack"><i class="bi bi-crosshair"></i> @Card.Attack</span>
|
|
||||||
}
|
|
||||||
@if (Card.Health.HasValue)
|
|
||||||
{
|
|
||||||
<span class="meta-badge health"><i class="bi bi-heart-fill"></i> @Card.Health</span>
|
|
||||||
}
|
|
||||||
@if (Card.Speed != null)
|
|
||||||
{
|
|
||||||
<span class="meta-badge speed"><i class="bi bi-wind"></i> @Card.Speed</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (Card.Syndicate != null)
|
|
||||||
{
|
|
||||||
<div class="detail-field">
|
|
||||||
<span class="field-label"><i class="bi bi-flag-fill"></i> Faction</span>
|
|
||||||
<span class="field-value">@Card.Syndicate</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Card.Description != null)
|
|
||||||
{
|
|
||||||
<div class="detail-field description">
|
|
||||||
<span class="field-label"><i class="bi bi-chat-quote-fill"></i></span>
|
|
||||||
<span class="field-value">@RenderDescription(Card.Description)</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Card.Set != null)
|
|
||||||
{
|
|
||||||
<div class="detail-field">
|
|
||||||
<span class="field-label"><i class="bi bi-collection"></i> Set</span>
|
|
||||||
<span class="field-value">@Card.Set</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Card.Archetypes is { Count: > 0 })
|
|
||||||
{
|
|
||||||
<div class="detail-field">
|
|
||||||
<span class="field-label"><i class="bi bi-layers-fill"></i> Archetypes</span>
|
|
||||||
<span class="field-value">@string.Join(", ", Card.Archetypes)</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Card.ImmortalizeWhen != null)
|
|
||||||
{
|
|
||||||
<div class="detail-field">
|
|
||||||
<span class="field-label"><i class="bi bi-star-fill"></i> Immortalize When</span>
|
|
||||||
<span class="field-value">@Card.ImmortalizeWhen</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Card.HasImmortalize)
|
|
||||||
{
|
|
||||||
<div class="detail-field">
|
|
||||||
<span class="field-label"><i class="bi bi-arrow-right-circle-fill"></i> Immortalizes To</span>
|
|
||||||
<span class="field-value">
|
|
||||||
@foreach (var name in Card.ImmortalizeTo!)
|
|
||||||
{
|
|
||||||
var targetName = name;
|
|
||||||
var target = LookupCard(targetName);
|
|
||||||
if (target != null)
|
|
||||||
{
|
|
||||||
<button class="inline-card-btn"
|
|
||||||
@onclick="() => Navigate(target)">@targetName</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@targetName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (Card.ImmortalizeFrom != null)
|
|
||||||
{
|
|
||||||
<div class="detail-field">
|
|
||||||
<span class="field-label"><i class="bi bi-arrow-left-circle-fill"></i> Immortalizes From</span>
|
|
||||||
<span class="field-value">
|
|
||||||
@{
|
|
||||||
var fromCard = LookupCard(Card.ImmortalizeFrom);
|
|
||||||
if (fromCard != null)
|
|
||||||
{
|
|
||||||
<button class="inline-card-btn"
|
|
||||||
@onclick="() => Navigate(fromCard)">@Card.ImmortalizeFrom</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@Card.ImmortalizeFrom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (ShowNotes && Card.IsAgent)
|
|
||||||
{
|
|
||||||
<div class="detail-field note">
|
|
||||||
<span class="field-label"><i class="bi bi-pencil-fill"></i> Personal Note</span>
|
|
||||||
<textarea class="form-control note-input" @bind="currentNote" @onblur="SaveNote"
|
|
||||||
placeholder="Add a private note about this agent..."></textarea>
|
|
||||||
@if (isSaving)
|
|
||||||
{
|
|
||||||
<span class="saving-indicator">Saving...</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public CardData? Card { get; set; }
|
|
||||||
[Parameter] public EventCallback OnClose { get; set; }
|
|
||||||
[Parameter] public EventCallback<CardData> OnNavigate { get; set; }
|
|
||||||
[Parameter] public bool ShowNotes { get; set; }
|
|
||||||
|
|
||||||
[Inject] private HttpClient Http { get; set; } = default!;
|
|
||||||
|
|
||||||
private bool HasImage => Card?.ImageFile is { Length: > 0 } && Card.ImageFile != "placeholder.png";
|
|
||||||
|
|
||||||
private string currentNote = "";
|
|
||||||
private bool isSaving;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
|
||||||
{
|
|
||||||
if (Card != null && Card.IsAgent)
|
|
||||||
{
|
|
||||||
currentNote = "";
|
|
||||||
_ = LoadNote();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadNote()
|
|
||||||
{
|
|
||||||
if (Card == null || !Card.IsAgent) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var note = await Http.GetFromJsonAsync<CardNote>($"api/notes/{Uri.EscapeDataString(Card.Name)}");
|
|
||||||
currentNote = note?.Note ?? "";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
currentNote = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveNote()
|
|
||||||
{
|
|
||||||
if (Card == null || !Card.IsAgent) return;
|
|
||||||
|
|
||||||
isSaving = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Http.PostAsJsonAsync("api/notes", new CardNote { CardName = Card.Name, Note = currentNote });
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
isSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleClose()
|
|
||||||
{
|
|
||||||
_ = OnClose.InvokeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleBackdropClick()
|
|
||||||
{
|
|
||||||
_ = OnClose.InvokeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Navigate(CardData card)
|
|
||||||
{
|
|
||||||
_ = OnNavigate.InvokeAsync(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CardData? LookupCard(string cardName)
|
|
||||||
{
|
|
||||||
return CardDatabase.Cards.FirstOrDefault(c =>
|
|
||||||
string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private RenderFragment RenderDescription(string description)
|
|
||||||
{
|
|
||||||
return builder =>
|
|
||||||
{
|
|
||||||
var cardNames = CardDatabase.Cards
|
|
||||||
.Select(c => c.Name)
|
|
||||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
|
||||||
.OrderByDescending(n => n.Length)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var seq = 0;
|
|
||||||
var remaining = description;
|
|
||||||
|
|
||||||
while (!string.IsNullOrEmpty(remaining))
|
|
||||||
{
|
|
||||||
var bestIdx = -1;
|
|
||||||
string? bestName = null;
|
|
||||||
|
|
||||||
foreach (var name in cardNames)
|
|
||||||
{
|
|
||||||
var pattern = $@"\b{Regex.Escape(name)}\b";
|
|
||||||
var match = Regex.Match(remaining, pattern, RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
if (match.Success)
|
|
||||||
{
|
|
||||||
if (bestIdx == -1 || match.Index < bestIdx)
|
|
||||||
{
|
|
||||||
bestIdx = match.Index;
|
|
||||||
bestName = match.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestIdx != -1 && bestName != null)
|
|
||||||
{
|
|
||||||
if (bestIdx > 0)
|
|
||||||
builder.AddContent(seq++, remaining[..bestIdx]);
|
|
||||||
|
|
||||||
var card = LookupCard(bestName);
|
|
||||||
if (card != null)
|
|
||||||
{
|
|
||||||
builder.OpenElement(seq++, "button");
|
|
||||||
builder.AddAttribute(seq++, "class", "inline-card-btn");
|
|
||||||
builder.AddAttribute(seq++, "onclick",
|
|
||||||
EventCallback.Factory.Create(this, () => Navigate(card)));
|
|
||||||
builder.AddContent(seq++, bestName);
|
|
||||||
builder.CloseElement();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.AddContent(seq++, bestName);
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining = remaining[(bestIdx + bestName.Length)..];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.AddContent(seq++, remaining);
|
|
||||||
remaining = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1040;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
animation: fade-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-detail {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 1050;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 0;
|
|
||||||
max-width: 850px;
|
|
||||||
width: 92vw;
|
|
||||||
max-height: 88vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
|
||||||
color: var(--text-primary);
|
|
||||||
animation: detail-enter 0.25s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes detail-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -50%) scale(0.92);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.75rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
z-index: 1;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
color: var(--text-primary);
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
transition: all var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-close:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-layout {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-image {
|
|
||||||
flex: 0 0 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-image img {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
max-height: 80vh;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── No-image layout ── */
|
|
||||||
.card-detail-noimg {
|
|
||||||
max-width: 460px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-noimg {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-header h2 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge.category.agent,
|
|
||||||
.meta-badge.category.immortalized {
|
|
||||||
background: rgba(79, 195, 247, 0.15);
|
|
||||||
color: #4fc3f7;
|
|
||||||
border-color: rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge.category.spell {
|
|
||||||
background: rgba(206, 147, 216, 0.15);
|
|
||||||
color: #ce93d8;
|
|
||||||
border-color: rgba(206, 147, 216, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge.cost {
|
|
||||||
background: rgba(255, 215, 0, 0.12);
|
|
||||||
color: var(--gold);
|
|
||||||
border-color: rgba(255, 215, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge.attack {
|
|
||||||
background: rgba(239, 83, 80, 0.15);
|
|
||||||
color: #ef5350;
|
|
||||||
border-color: rgba(239, 83, 80, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge.health {
|
|
||||||
background: rgba(102, 187, 106, 0.15);
|
|
||||||
color: #66bb6a;
|
|
||||||
border-color: rgba(102, 187, 106, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-badge.speed {
|
|
||||||
background: rgba(79, 195, 247, 0.12);
|
|
||||||
color: #4fc3f7;
|
|
||||||
border-color: rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-field {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-field.description {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
min-width: 7.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-field.description .field-label {
|
|
||||||
min-width: auto;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-value {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-field.description .field-value {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-detail::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-detail::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-card-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
text-decoration-color: rgba(108, 99, 255, 0.3);
|
|
||||||
transition: color var(--transition), text-decoration-color var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-card-btn:hover {
|
|
||||||
color: #7b73ff;
|
|
||||||
text-decoration-color: #7b73ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.detail-layout {
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-image {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
max-width: 180px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-detail,
|
|
||||||
.card-detail-noimg {
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-detail-noimg {
|
|
||||||
max-width: 92vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<div class="top-row ps-3 navbar navbar-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="">
|
|
||||||
<i class="bi bi-hourglass-split me-2 text-warning"></i> Chrono CCG
|
|
||||||
</a>
|
|
||||||
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="home">
|
|
||||||
<i class="bi bi-house-door-fill nav-icon"></i> Home
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
|
||||||
<i class="bi bi-collection-fill nav-icon"></i> Cards
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="agents">
|
|
||||||
<i class="bi bi-people-fill nav-icon"></i> Agents
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="decks">
|
|
||||||
<i class="bi bi-journal-text nav-icon"></i> Decks
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="syndicates">
|
|
||||||
<i class="bi bi-diagram-2-fill nav-icon"></i> Syndicates
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="my-decks">
|
|
||||||
<i class="bi bi-pencil-square nav-icon"></i> My Decks
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="deck-builder">
|
|
||||||
<i class="bi bi-hammer nav-icon"></i> Deck Builder
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="docs">
|
|
||||||
<i class="bi bi-book-fill nav-icon"></i> Docs
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool collapseNavMenu = true;
|
|
||||||
|
|
||||||
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
|
|
||||||
|
|
||||||
private void ToggleNavMenu()
|
|
||||||
{
|
|
||||||
collapseNavMenu = !collapseNavMenu;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
@page "/"
|
|
||||||
@page "/cards"
|
|
||||||
@using Chrono.Components
|
|
||||||
@using Shared.Services
|
|
||||||
@inject AnalyticsService AnalyticsService
|
|
||||||
|
|
||||||
<PageTitle>Chrono CCG - Card Gallery</PageTitle>
|
|
||||||
|
|
||||||
<HeadContent>
|
|
||||||
<meta name="description" content="Browse and filter all Chrono CCG cards. Explore agents, spells, and more."/>
|
|
||||||
</HeadContent>
|
|
||||||
|
|
||||||
<div class="gallery-container">
|
|
||||||
<div class="gallery-header">
|
|
||||||
<h1>Card Gallery</h1>
|
|
||||||
<p class="text-secondary">Browse all @allCards.Count cards</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="category-tabs">
|
|
||||||
<button class="tab @(categoryFilter == "" ? "active" : "")" @onclick='@(() => SetCategory(""))'>
|
|
||||||
<i class="bi bi-grid-3x3-gap-fill"></i> All
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="tab agent @(categoryFilter == "Agent" ? "active" : "")" @onclick='@(() => SetCategory("Agent"))'>
|
|
||||||
<i class="bi bi-person-fill"></i> Agents
|
|
||||||
</button>
|
|
||||||
<button class="tab agent @(categoryFilter == "Immortalized" ? "active" : "")"
|
|
||||||
@onclick='@(() => SetCategory("Immortalized"))'>
|
|
||||||
<i class="bi bi-person-fill"></i> Immortalized
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="tab link-toggle @(agentLink ? "active" : "")" @onclick="ToggleAgentLink"
|
|
||||||
title="Link Agents and Immortalized cards">
|
|
||||||
<i class="bi bi-link-45deg"></i>
|
|
||||||
<span>Linked</span>
|
|
||||||
<span class="toggle-switch @(agentLink ? "on" : "off")"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="category-divider"></div>
|
|
||||||
|
|
||||||
<button class="tab spell @(categoryFilter == "Spell" ? "active" : "")" @onclick='@(() => SetCategory("Spell"))'>
|
|
||||||
<i class="bi bi-wand"></i> Spells
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="search-wrapper">
|
|
||||||
<i class="bi bi-search search-icon"></i>
|
|
||||||
<input @bind="search" @bind:event="oninput" class="form-control search-input"
|
|
||||||
placeholder="Search cards by name or description..."/>
|
|
||||||
@if (search.Length > 0)
|
|
||||||
{
|
|
||||||
<button class="search-clear" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<select @bind="factionFilter" class="form-select filter-select">
|
|
||||||
<option value="">All Factions</option>
|
|
||||||
@foreach (var f in factions)
|
|
||||||
{
|
|
||||||
<option value="@f">@f</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<select @bind="costFilter" class="form-select filter-select">
|
|
||||||
<option value="">All Costs</option>
|
|
||||||
@for (var i = 0; i <= 13; i++)
|
|
||||||
{
|
|
||||||
<option value="@i">@i</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<div class="sort-group d-flex gap-1">
|
|
||||||
<select @bind="sortBy" class="form-select filter-select sort-select">
|
|
||||||
<option value="Cost">Sort by Cost</option>
|
|
||||||
<option value="Name">Sort by Name</option>
|
|
||||||
<option value="Attack">Sort by Attack</option>
|
|
||||||
<option value="Health">Sort by Health</option>
|
|
||||||
<option value="Efficiency">Sort by Efficiency</option>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-outline-secondary sort-dir-btn" @onclick="() => sortDescending = !sortDescending"
|
|
||||||
title="@(sortDescending ? "Descending" : "Ascending")">
|
|
||||||
<i class="bi bi-sort-@(sortDescending ? "down" : "up")"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="btn @(showDetailedView ? "btn-primary" : "btn-outline-primary") view-toggle-btn"
|
|
||||||
@onclick="() => showDetailedView = !showDetailedView" title="Toggle Detailed View">
|
|
||||||
<i class="bi bi-list-ul"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (HasActiveFilters)
|
|
||||||
{
|
|
||||||
<div class="active-filters">
|
|
||||||
<span class="filter-label">Filters:</span>
|
|
||||||
@if (categoryFilter != "")
|
|
||||||
{
|
|
||||||
<span class="filter-chip">
|
|
||||||
<i class="bi bi-tag-fill"></i> @categoryFilter
|
|
||||||
<button @onclick="ClearCategoryFilter"><i class="bi bi-x"></i></button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (factionFilter != "")
|
|
||||||
{
|
|
||||||
<span class="filter-chip">
|
|
||||||
<i class="bi bi-flag-fill"></i> @factionFilter
|
|
||||||
<button @onclick="ClearFactionFilter"><i class="bi bi-x"></i></button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (costFilter != "")
|
|
||||||
{
|
|
||||||
<span class="filter-chip">
|
|
||||||
<i class="bi bi-lightning-fill"></i> Cost @costFilter
|
|
||||||
<button @onclick="ClearCostFilter"><i class="bi bi-x"></i></button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<button class="clear-all" @onclick="ClearFilters">Clear all</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="result-count">
|
|
||||||
<span>@filteredCards.Count() card@(filteredCards.Count() != 1 ? "s" : "") found</span>
|
|
||||||
@if (HasActiveFilters || search != "")
|
|
||||||
{
|
|
||||||
<span class="text-muted">out of @allCards.Count total</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filteredCards.Any())
|
|
||||||
{
|
|
||||||
<div class="card-grid @(showDetailedView ? "detailed-view" : "")">
|
|
||||||
@{ var idx = 0; }
|
|
||||||
@foreach (var card in filteredCards.Where(a => a.Category is "Agent" or "Spell" or "Immortalized"))
|
|
||||||
{
|
|
||||||
<div class="card-cell @(selectedCard == card ? "selected" : "")"
|
|
||||||
style="--i: @idx"
|
|
||||||
@onclick="() => SelectCard(card)">
|
|
||||||
<div class="card-image-wrapper">
|
|
||||||
<div class="card-shimmer"></div>
|
|
||||||
<img src="@card.ImagePath" alt="@card.Name" loading="lazy"
|
|
||||||
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22280%22><rect fill=%22%23222244%22 width=%22200%22 height=%22280%22/><text fill=%22%23686888%22 font-size=%2214%22 x=%22100%22 y=%22140%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22>No Image</text></svg>'"/>
|
|
||||||
|
|
||||||
@if (card.IsSpell && !string.IsNullOrEmpty(card.Speed))
|
|
||||||
{
|
|
||||||
<div class="card-speed-badge" title="Speed">
|
|
||||||
<i class="bi bi-wind"></i> @card.Speed
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (card.IsImmortalized)
|
|
||||||
{
|
|
||||||
<div class="card-immortalize-badge" title="Immortalizes"><i class="bi bi-star-fill"></i>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (showDetailedView)
|
|
||||||
{
|
|
||||||
<div class="card-label">
|
|
||||||
<div class="card-name">@card.Name</div>
|
|
||||||
<div class="card-stats">
|
|
||||||
@if (card.Attack.HasValue)
|
|
||||||
{
|
|
||||||
<span class="stat"><i class="bi bi-crosshair"></i> @card.Attack</span>
|
|
||||||
}
|
|
||||||
@if (card.Health.HasValue)
|
|
||||||
{
|
|
||||||
<span class="stat"><i class="bi bi-heart-fill"></i> @card.Health</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="card-description-preview">@card.Description</div>
|
|
||||||
<div class="card-category-badge @card.Category?.ToLowerInvariant()">@card.Category</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="bi bi-search"></i>
|
|
||||||
<p>No cards match your filters.</p>
|
|
||||||
<button class="btn btn-outline-secondary" @onclick="ClearFilters">Reset Filters</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (selectedCard != null)
|
|
||||||
{
|
|
||||||
<CardDialog Card="selectedCard" OnClose="CloseDetail" OnNavigate="SelectCard"/>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<CardData> allCards = [];
|
|
||||||
private IEnumerable<CardData> filteredCards => ApplyFilters();
|
|
||||||
private string search = "";
|
|
||||||
private string categoryFilter = "";
|
|
||||||
private string immortalFilter = "";
|
|
||||||
private bool agentLink = true;
|
|
||||||
private string factionFilter = "";
|
|
||||||
private string costFilter = "";
|
|
||||||
private string sortBy = "Cost";
|
|
||||||
private bool sortDescending;
|
|
||||||
private bool showDetailedView;
|
|
||||||
private CardData? selectedCard;
|
|
||||||
private List<string> factions = [];
|
|
||||||
|
|
||||||
private bool HasActiveFilters => categoryFilter != "" || factionFilter != "" || costFilter != "" || immortalFilter != "";
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
allCards = CardDatabase.Cards;
|
|
||||||
factions = allCards
|
|
||||||
.Select(c => c.Syndicate)
|
|
||||||
.Where(f => f != null)
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(f => f)
|
|
||||||
.ToList()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<CardData> ApplyFilters()
|
|
||||||
{
|
|
||||||
var primarySet = allCards.Where(c =>
|
|
||||||
c.MatchesSearch(search) &&
|
|
||||||
(categoryFilter == "" || c.Category == categoryFilter) &&
|
|
||||||
(factionFilter == "" || c.Syndicate == factionFilter) &&
|
|
||||||
(costFilter == "" || c.Cost?.ToString() == costFilter)
|
|
||||||
);
|
|
||||||
|
|
||||||
var sortedPrimary = sortBy switch
|
|
||||||
{
|
|
||||||
"Cost" => sortDescending ? primarySet.OrderByDescending(c => c.Cost) : primarySet.OrderBy(c => c.Cost),
|
|
||||||
"Efficiency" => sortDescending ? primarySet.OrderByDescending(c => c.StatEfficiency) : primarySet.OrderBy(c => c.StatEfficiency),
|
|
||||||
"Attack" => sortDescending ? primarySet.OrderByDescending(c => c.Attack) : primarySet.OrderBy(c => c.Attack),
|
|
||||||
"Health" => sortDescending ? primarySet.OrderByDescending(c => c.Health) : primarySet.OrderBy(c => c.Health),
|
|
||||||
_ => sortDescending ? primarySet.OrderByDescending(c => c.Name) : primarySet.OrderBy(c => c.Name)
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!agentLink)
|
|
||||||
{
|
|
||||||
return sortedPrimary;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new List<CardData>();
|
|
||||||
var seen = new HashSet<string>();
|
|
||||||
foreach (var card in sortedPrimary)
|
|
||||||
{
|
|
||||||
if (seen.Contains(card.Name)) continue;
|
|
||||||
|
|
||||||
// Determine the "Chain Head" (the Agent)
|
|
||||||
var head = card;
|
|
||||||
if (card.Category == "Immortalized" && card.ImmortalizeFrom != null)
|
|
||||||
{
|
|
||||||
var source = allCards.FirstOrDefault(c => c.Name == card.ImmortalizeFrom);
|
|
||||||
if (source != null) head = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the chain starting from head
|
|
||||||
if (!seen.Contains(head.Name))
|
|
||||||
{
|
|
||||||
result.Add(head);
|
|
||||||
seen.Add(head.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (head.ImmortalizeTo != null)
|
|
||||||
{
|
|
||||||
foreach (var targetName in head.ImmortalizeTo)
|
|
||||||
{
|
|
||||||
if (seen.Contains(targetName)) continue;
|
|
||||||
var linked = allCards.FirstOrDefault(c => c.Name == targetName);
|
|
||||||
if (linked != null)
|
|
||||||
{
|
|
||||||
result.Add(linked);
|
|
||||||
seen.Add(linked.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the original card is added if it was somehow not part of the head's chain
|
|
||||||
if (!seen.Contains(card.Name))
|
|
||||||
{
|
|
||||||
result.Add(card);
|
|
||||||
seen.Add(card.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetImmortal(string imm)
|
|
||||||
{
|
|
||||||
immortalFilter = immortalFilter == imm ? "" : imm;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearImmortalFilter()
|
|
||||||
{
|
|
||||||
immortalFilter = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetCategory(string cat)
|
|
||||||
{
|
|
||||||
categoryFilter = categoryFilter == cat ? "" : cat;
|
|
||||||
_ = AnalyticsService.TrackEvent("filter_category", new { category = categoryFilter });
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearCategoryFilter()
|
|
||||||
{
|
|
||||||
categoryFilter = "";
|
|
||||||
_ = AnalyticsService.TrackEvent("filter_category", new { category = "All" });
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearFactionFilter()
|
|
||||||
{
|
|
||||||
factionFilter = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleAgentLink()
|
|
||||||
{
|
|
||||||
agentLink = !agentLink;
|
|
||||||
OnToggleAgentLink();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnToggleAgentLink()
|
|
||||||
{
|
|
||||||
_ = AnalyticsService.TrackEvent("toggle_agent_link", new { enabled = agentLink });
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearCostFilter()
|
|
||||||
{
|
|
||||||
costFilter = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearSearch()
|
|
||||||
{
|
|
||||||
search = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SelectCard(CardData card)
|
|
||||||
{
|
|
||||||
selectedCard = card;
|
|
||||||
_ = AnalyticsService.TrackEvent("select_item", new { item_name = card.Name, item_category = card.Category });
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseDetail()
|
|
||||||
{
|
|
||||||
selectedCard = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearFilters()
|
|
||||||
{
|
|
||||||
search = "";
|
|
||||||
categoryFilter = "";
|
|
||||||
factionFilter = "";
|
|
||||||
costFilter = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
.gallery-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ── */
|
|
||||||
.gallery-header {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-header h1 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-header p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Category Tabs ── */
|
|
||||||
.category-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 100px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 12px var(--accent-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active.agent {
|
|
||||||
border-color: #4fc3f7;
|
|
||||||
box-shadow: 0 0 12px rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active.spell {
|
|
||||||
border-color: #ce93d8;
|
|
||||||
box-shadow: 0 0 12px rgba(206, 147, 216, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active.token {
|
|
||||||
border-color: #ffd54f;
|
|
||||||
box-shadow: 0 0 12px rgba(255, 213, 79, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab i {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 1.5rem;
|
|
||||||
background: var(--border);
|
|
||||||
margin: 0 0.6rem;
|
|
||||||
align-self: center;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.link-toggle {
|
|
||||||
border-style: dashed;
|
|
||||||
gap: 0.6rem;
|
|
||||||
user-select: none;
|
|
||||||
padding-right: 0.8rem;
|
|
||||||
margin-left: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.link-toggle.active {
|
|
||||||
border-style: solid;
|
|
||||||
background: rgba(108, 99, 255, 0.1);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch {
|
|
||||||
width: 28px;
|
|
||||||
height: 16px;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-radius: 10px;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch.on {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
top: 2px;
|
|
||||||
left: 2px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch.on::after {
|
|
||||||
transform: translateX(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Filter Bar ── */
|
|
||||||
.filter-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrapper {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.75rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: var(--text-muted);
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
padding-left: 2.2rem;
|
|
||||||
padding-right: 2.2rem;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.5rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
z-index: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-select {
|
|
||||||
width: auto;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Active Filter Chips ── */
|
|
||||||
.active-filters {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 100px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip i:first-child {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-chip button:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-all {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--accent);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clear-all:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Result Count ── */
|
|
||||||
.result-count {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card Grid ── */
|
|
||||||
.card-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid.detailed-view {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid.detailed-view .card-cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 180px;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid.detailed-view .card-image-wrapper {
|
|
||||||
width: 130px;
|
|
||||||
height: 100%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid.detailed-view .card-label {
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 0.75rem;
|
|
||||||
gap: 0.4rem;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid.detailed-view .card-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: normal;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-stats .stat {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-stats .stat i {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-stats .stat:nth-child(1) {
|
|
||||||
color: #ffab40;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Attack */
|
|
||||||
.card-stats .stat:nth-child(2) {
|
|
||||||
color: #f44336;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Health */
|
|
||||||
|
|
||||||
.card-description-preview {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.4;
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-group {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-select {
|
|
||||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-dir-btn {
|
|
||||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|
||||||
border-left: none;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-toggle-btn {
|
|
||||||
min-width: 40px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card Cell ── */
|
|
||||||
.card-cell {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
animation: card-enter 0.4s ease-out both;
|
|
||||||
animation-delay: calc(var(--i, 0) * 25ms);
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cell:hover {
|
|
||||||
transform: translateY(-6px);
|
|
||||||
box-shadow: 0 8px 30px rgba(108, 99, 255, 0.2);
|
|
||||||
border-color: rgba(108, 99, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cell.selected {
|
|
||||||
border-color: var(--gold);
|
|
||||||
box-shadow: 0 0 20px var(--gold-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cell.selected:hover {
|
|
||||||
box-shadow: 0 8px 30px var(--gold-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes card-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(16px) scale(0.97);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card Image ── */
|
|
||||||
.card-image-wrapper {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 5 / 7;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #111128;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-shimmer {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.03) 50%, transparent 100%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: shimmer 2.5s infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 200% 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-image-wrapper img {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cost-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.4rem;
|
|
||||||
left: 0.4rem;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
color: var(--gold);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0.15rem 0.5rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
|
||||||
z-index: 2;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-cost-badge::before {
|
|
||||||
content: "⚡";
|
|
||||||
margin-right: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-immortalize-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.4rem;
|
|
||||||
right: 0.4rem;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
color: var(--gold);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 2;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-speed-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.4rem;
|
|
||||||
right: 0.4rem;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
color: #ce93d8; /* Match spell category color */
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(206, 147, 216, 0.3);
|
|
||||||
z-index: 2;
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-speed-badge i {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Card Label ── */
|
|
||||||
.card-label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
min-height: 2.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-name {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-category-badge {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-category-badge.agent {
|
|
||||||
background: rgba(79, 195, 247, 0.15);
|
|
||||||
color: #4fc3f7;
|
|
||||||
border-color: rgba(79, 195, 247, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-category-badge.spell {
|
|
||||||
background: rgba(206, 147, 216, 0.15);
|
|
||||||
color: #ce93d8;
|
|
||||||
border-color: rgba(206, 147, 216, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-category-badge.token {
|
|
||||||
background: rgba(255, 213, 79, 0.15);
|
|
||||||
color: #ffd54f;
|
|
||||||
border-color: rgba(255, 213, 79, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty State ── */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state i {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.gallery-container {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
max-width: 400px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-grid.detailed-view {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.card-grid, .card-grid.detailed-view {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 0.5rem;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category-tabs {
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab i {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
@page "/deck-builder"
|
|
||||||
@page "/deck-builder/{Id:guid}"
|
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
@inject AppDbContext Db
|
|
||||||
|
|
||||||
<PageTitle>@(Id == null ? "New Deck" : "Edit Deck") - Chrono CCG</PageTitle>
|
|
||||||
|
|
||||||
<div class="builder-topbar">
|
|
||||||
<a href="/my-decks" class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-left"></i> My Decks
|
|
||||||
</a>
|
|
||||||
<input @bind="deckName" @bind:event="oninput" class="form-control deck-name-input" placeholder="Deck name..."
|
|
||||||
maxlength="80"/>
|
|
||||||
<input @bind="deckSeason" @bind:event="oninput" class="form-control season-input" placeholder="Season (e.g. S1)"
|
|
||||||
maxlength="20"/>
|
|
||||||
<span class="validity-badge @(IsValid ? "badge-valid" : "badge-invalid")">
|
|
||||||
<i class="bi @(IsValid ? "bi-check-circle-fill" : "bi-exclamation-circle-fill")"></i>
|
|
||||||
@ValidationMessage
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-primary save-btn" @onclick="SaveDeck" disabled="@isSaving">
|
|
||||||
@if (isSaving)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="bi bi-floppy-fill me-1"></i>
|
|
||||||
}
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
@if (!string.IsNullOrEmpty(saveMessage))
|
|
||||||
{
|
|
||||||
<span class="save-toast @(saveError ? "save-error" : "save-ok")">@saveMessage</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="builder-layout">
|
|
||||||
|
|
||||||
<!-- LEFT: Card Browser -->
|
|
||||||
<div class="browser-panel">
|
|
||||||
<div class="category-tabs">
|
|
||||||
<button class="tab @(categoryFilter == "" ? "active" : "")" @onclick='() => categoryFilter = ""'>
|
|
||||||
<i class="bi bi-grid-3x3-gap-fill"></i> All
|
|
||||||
</button>
|
|
||||||
<button class="tab agent @(categoryFilter == "Agent" ? "active" : "")"
|
|
||||||
@onclick='() => categoryFilter = "Agent"'>
|
|
||||||
<i class="bi bi-person-fill"></i> Agents
|
|
||||||
</button>
|
|
||||||
<button class="tab agent @(categoryFilter == "Immortalized" ? "active" : "")"
|
|
||||||
@onclick='() => categoryFilter = "Immortalized"'>
|
|
||||||
<i class="bi bi-star-fill"></i> Immortalized
|
|
||||||
</button>
|
|
||||||
<button class="tab spell @(categoryFilter == "Spell" ? "active" : "")"
|
|
||||||
@onclick='() => categoryFilter = "Spell"'>
|
|
||||||
<i class="bi bi-wand"></i> Spells
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="search-wrapper">
|
|
||||||
<i class="bi bi-search search-icon"></i>
|
|
||||||
<input @bind="search" @bind:event="oninput" class="form-control search-input"
|
|
||||||
placeholder="Search cards..."/>
|
|
||||||
@if (search.Length > 0)
|
|
||||||
{
|
|
||||||
<button class="search-clear" @onclick='() => search = ""'><i class="bi bi-x-lg"></i></button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<select @bind="factionFilter" class="form-select filter-select">
|
|
||||||
<option value="">All Factions</option>
|
|
||||||
@foreach (var f in factions)
|
|
||||||
{
|
|
||||||
<option value="@f">@f</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<div class="col-picker" title="Cards per row">
|
|
||||||
@for (var c = 1; c <= 6; c++)
|
|
||||||
{
|
|
||||||
var cols = c;
|
|
||||||
<button class="col-btn @(gridColumns == cols ? "active" : "")"
|
|
||||||
@onclick="() => gridColumns = cols">@cols</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="browser-grid" style="--grid-cols: @gridColumns">
|
|
||||||
@foreach (var card in BrowserCards)
|
|
||||||
{
|
|
||||||
var count = CountInDeck(card.Name);
|
|
||||||
var maxed = count >= 3;
|
|
||||||
<div @key="card.Name" class="browser-card @(count > 0 ? "in-deck" : "") @(maxed ? "maxed" : "")"
|
|
||||||
@onclick="() => AddCard(card)"
|
|
||||||
title="@(count > 0 ? $"{card.Name} ({count}/3)" : card.Name)">
|
|
||||||
<div class="browser-card-img">
|
|
||||||
<img src="@card.ImagePath" alt="@card.Name" loading="lazy"
|
|
||||||
onerror="this.style.display='none'"/>
|
|
||||||
@if (count > 0)
|
|
||||||
{
|
|
||||||
<span class="in-deck-count">@count</span>
|
|
||||||
}
|
|
||||||
@if (maxed)
|
|
||||||
{
|
|
||||||
<div class="maxed-overlay"><i class="bi bi-lock-fill"></i></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="browser-card-name">@card.Name</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT: Deck Panel -->
|
|
||||||
<div class="deck-panel">
|
|
||||||
|
|
||||||
<div class="deck-status-bar">
|
|
||||||
<span
|
|
||||||
class="card-count @(deckCards.Count == 40 ? "count-ok" : deckCards.Count > 40 ? "count-over" : "count-under")">
|
|
||||||
<i class="bi bi-stack"></i> @deckCards.Count<span class="count-denom">/40</span>
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-outline-danger ms-auto" @onclick="ClearDeck" title="Clear deck">
|
|
||||||
<i class="bi bi-trash3"></i> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (deckCards.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="mana-curve">
|
|
||||||
@for (var cost = 0; cost <= 10; cost++)
|
|
||||||
{
|
|
||||||
var c = cost;
|
|
||||||
var costCount = ManaCurveData.TryGetValue(c, out var v) ? v : 0;
|
|
||||||
var maxVal = ManaCurveData.Values.DefaultIfEmpty(1).Max();
|
|
||||||
var heightPct = maxVal > 0 ? costCount * 100 / maxVal : 0;
|
|
||||||
<div class="curve-col">
|
|
||||||
<div class="curve-bar-wrap">
|
|
||||||
<span class="curve-count">@(costCount > 0 ? costCount.ToString() : "")</span>
|
|
||||||
<div class="curve-bar" style="height: @heightPct%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="curve-label">@(c == 10 ? "10+" : c.ToString())</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="deck-card-list">
|
|
||||||
@if (DeckGroups.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="deck-empty">
|
|
||||||
<i class="bi bi-collection"></i>
|
|
||||||
<span>Click cards on the left to add them</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@foreach (var group in DeckGroups)
|
|
||||||
{
|
|
||||||
var cardData = group.Card;
|
|
||||||
<div class="deck-row">
|
|
||||||
<img src="@cardData.ImagePath" alt="@cardData.Name" class="deck-row-img" loading="lazy"
|
|
||||||
onerror="this.style.display='none'"/>
|
|
||||||
<span class="deck-row-name">@cardData.Name</span>
|
|
||||||
@if (cardData.Cost.HasValue)
|
|
||||||
{
|
|
||||||
<span class="deck-row-cost"><i class="bi bi-lightning-fill"></i>@cardData.Cost</span>
|
|
||||||
}
|
|
||||||
<div class="deck-row-controls">
|
|
||||||
<button class="count-btn" @onclick="() => RemoveCard(cardData.Name)">−</button>
|
|
||||||
<span class="count-display">@group.Count</span>
|
|
||||||
<button class="count-btn" @onclick="() => AddCard(cardData)" disabled="@(group.Count >= 3)">+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divers-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<i class="bi bi-person-arms-up"></i> Divers
|
|
||||||
<span class="section-count">@divers.Count(d => d != null)/2</span>
|
|
||||||
</div>
|
|
||||||
@for (var i = 0; i < 2; i++)
|
|
||||||
{
|
|
||||||
var idx = i;
|
|
||||||
var diver = idx < divers.Count ? divers[idx] : null;
|
|
||||||
<div class="diver-slot @(diver != null ? "filled" : "empty")" @onclick="() => OpenDiverPicker(idx)">
|
|
||||||
@if (diver != null)
|
|
||||||
{
|
|
||||||
var diverCard = AllCards.FirstOrDefault(c => c.Name == diver);
|
|
||||||
@if (diverCard != null)
|
|
||||||
{
|
|
||||||
<img src="@diverCard.ImagePath" class="diver-thumb" alt="@diver"
|
|
||||||
onerror="this.style.display='none'"/>
|
|
||||||
}
|
|
||||||
|
|
||||||
<span class="diver-name">@diver</span>
|
|
||||||
<button class="diver-remove" @onclick:stopPropagation="true" @onclick="() => RemoveDiver(idx)">
|
|
||||||
<i class="bi bi-x"></i>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<i class="bi bi-plus-circle"></i>
|
|
||||||
<span>Select Diver @(idx + 1)</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="notes-section">
|
|
||||||
<div class="section-header"><i class="bi bi-pencil-fill"></i> Notes</div>
|
|
||||||
<textarea @bind="deckNotes" class="form-control notes-input" rows="4"
|
|
||||||
placeholder="Strategy notes, matchup tips, card choices..."></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (showDiverPicker)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop" @onclick="CloseDiverPicker"></div>
|
|
||||||
<div class="diver-modal">
|
|
||||||
<div class="diver-modal-header">
|
|
||||||
<h5><i class="bi bi-person-arms-up"></i> Select Diver @(activeDiverSlot + 1)</h5>
|
|
||||||
<button class="btn-close" @onclick="CloseDiverPicker"></button>
|
|
||||||
</div>
|
|
||||||
<div class="filter-bar mb-2">
|
|
||||||
<div class="search-wrapper">
|
|
||||||
<i class="bi bi-search search-icon"></i>
|
|
||||||
<input @bind="diverSearch" @bind:event="oninput" class="form-control search-input"
|
|
||||||
placeholder="Search..."/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="diver-picker-grid">
|
|
||||||
@foreach (var card in DiverPickerCards)
|
|
||||||
{
|
|
||||||
<div class="diver-picker-card" @onclick="() => SelectDiver(card.Name)">
|
|
||||||
<img src="@card.ImagePath" alt="@card.Name" loading="lazy" onerror="this.style.display='none'"/>
|
|
||||||
<span>@card.Name</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public Guid? Id { get; set; }
|
|
||||||
|
|
||||||
private List<CardData> AllCards => CardDatabase.Cards;
|
|
||||||
private List<string> factions = [];
|
|
||||||
|
|
||||||
private string deckName = "";
|
|
||||||
private string deckSeason = "";
|
|
||||||
private string deckNotes = "";
|
|
||||||
private List<string> deckCards = [];
|
|
||||||
private List<string> divers = [];
|
|
||||||
|
|
||||||
private string search = "";
|
|
||||||
private string categoryFilter = "";
|
|
||||||
private string factionFilter = "";
|
|
||||||
private int gridColumns = 4;
|
|
||||||
|
|
||||||
private bool showDiverPicker;
|
|
||||||
private int activeDiverSlot;
|
|
||||||
private string diverSearch = "";
|
|
||||||
|
|
||||||
private bool isSaving;
|
|
||||||
private string saveMessage = "";
|
|
||||||
private bool saveError;
|
|
||||||
|
|
||||||
private bool IsValid => deckCards.Count == 40 && divers.Count == 2 && divers.All(d => d != null);
|
|
||||||
|
|
||||||
private string ValidationMessage => (deckCards.Count, divers.Count) switch
|
|
||||||
{
|
|
||||||
(< 40, _) => $"{40 - deckCards.Count} cards short",
|
|
||||||
(> 40, _) => $"{deckCards.Count - 40} cards over",
|
|
||||||
(40, < 2) => "Need 2 divers",
|
|
||||||
_ => "Valid"
|
|
||||||
};
|
|
||||||
|
|
||||||
private IEnumerable<CardData> BrowserCards => AllCards
|
|
||||||
.Where(c => c.Category is "Agent" or "Spell" or "Immortalized")
|
|
||||||
.Where(c => categoryFilter == "" || c.Category == categoryFilter)
|
|
||||||
.Where(c => factionFilter == "" || c.Syndicate == factionFilter)
|
|
||||||
.Where(c => search == "" || c.MatchesSearch(search));
|
|
||||||
|
|
||||||
private record DeckGroup(CardData Card, int Count);
|
|
||||||
|
|
||||||
private List<DeckGroup> DeckGroups => deckCards
|
|
||||||
.GroupBy(name => name)
|
|
||||||
.Select(g => new DeckGroup(AllCards.First(c => c.Name == g.Key), g.Count()))
|
|
||||||
.OrderBy(g => g.Card.Cost ?? 99)
|
|
||||||
.ThenBy(g => g.Card.Name)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
private IEnumerable<CardData> DiverPickerCards
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var inDeck = deckCards.ToHashSet();
|
|
||||||
var otherDiverSlot = activeDiverSlot == 0
|
|
||||||
? divers.Count > 1 ? divers[1] : null
|
|
||||||
: divers.Count > 0
|
|
||||||
? divers[0]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return AllCards
|
|
||||||
.Where(c => c.Category is "Agent" or "Spell" or "Immortalized")
|
|
||||||
.Where(c => !inDeck.Contains(c.Name))
|
|
||||||
.Where(c => c.Name != otherDiverSlot)
|
|
||||||
.Where(c => diverSearch == "" || c.MatchesSearch(diverSearch));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Dictionary<int, int> ManaCurveData
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var result = new Dictionary<int, int>();
|
|
||||||
foreach (var name in deckCards)
|
|
||||||
{
|
|
||||||
var card = AllCards.FirstOrDefault(c => c.Name == name);
|
|
||||||
if (card == null) continue;
|
|
||||||
var bucket = Math.Min(card.Cost ?? 0, 10);
|
|
||||||
result[bucket] = result.GetValueOrDefault(bucket) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Dictionary<string, int> _deckCounts = new();
|
|
||||||
|
|
||||||
private void RebuildDeckCounts()
|
|
||||||
{
|
|
||||||
_deckCounts = deckCards.GroupBy(n => n).ToDictionary(g => g.Key, g => g.Count());
|
|
||||||
}
|
|
||||||
|
|
||||||
private int CountInDeck(string name)
|
|
||||||
{
|
|
||||||
return _deckCounts.TryGetValue(name, out var c) ? c : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
|
||||||
{
|
|
||||||
factions = AllCards
|
|
||||||
.Where(c => c.Syndicate != null)
|
|
||||||
.Select(c => c.Syndicate!)
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(f => f)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (Id.HasValue)
|
|
||||||
await LoadDeck(Id.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadDeck(Guid id)
|
|
||||||
{
|
|
||||||
var deck = await Db.UserDecks.FindAsync(id);
|
|
||||||
if (deck == null) return;
|
|
||||||
|
|
||||||
deckName = deck.Name;
|
|
||||||
deckSeason = deck.Season ?? "";
|
|
||||||
deckNotes = deck.Notes ?? "";
|
|
||||||
deckCards = new List<string>(deck.Cards);
|
|
||||||
divers = new List<string>(deck.Divers);
|
|
||||||
RebuildDeckCounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AddCard(CardData card)
|
|
||||||
{
|
|
||||||
if (CountInDeck(card.Name) >= 3) return;
|
|
||||||
deckCards.Add(card.Name);
|
|
||||||
RebuildDeckCounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveCard(string name)
|
|
||||||
{
|
|
||||||
var idx = deckCards.LastIndexOf(name);
|
|
||||||
if (idx >= 0) deckCards.RemoveAt(idx);
|
|
||||||
RebuildDeckCounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearDeck()
|
|
||||||
{
|
|
||||||
deckCards.Clear();
|
|
||||||
RebuildDeckCounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenDiverPicker(int slot)
|
|
||||||
{
|
|
||||||
activeDiverSlot = slot;
|
|
||||||
diverSearch = "";
|
|
||||||
showDiverPicker = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseDiverPicker()
|
|
||||||
{
|
|
||||||
showDiverPicker = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SelectDiver(string name)
|
|
||||||
{
|
|
||||||
while (divers.Count <= activeDiverSlot)
|
|
||||||
divers.Add(null!);
|
|
||||||
divers[activeDiverSlot] = name;
|
|
||||||
showDiverPicker = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RemoveDiver(int slot)
|
|
||||||
{
|
|
||||||
if (slot < divers.Count)
|
|
||||||
divers.RemoveAt(slot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveDeck()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(deckName))
|
|
||||||
{
|
|
||||||
saveMessage = "Please enter a deck name.";
|
|
||||||
saveError = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving = true;
|
|
||||||
saveMessage = "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (Id.HasValue)
|
|
||||||
{
|
|
||||||
var existing = await Db.UserDecks.FindAsync(Id.Value);
|
|
||||||
if (existing != null)
|
|
||||||
{
|
|
||||||
existing.Name = deckName;
|
|
||||||
existing.Cards = new List<string>(deckCards);
|
|
||||||
existing.Divers = new List<string>(divers);
|
|
||||||
existing.Notes = deckNotes;
|
|
||||||
existing.Season = deckSeason;
|
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await Db.SaveChangesAsync();
|
|
||||||
saveMessage = "Saved!";
|
|
||||||
saveError = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var deck = new UserDeck
|
|
||||||
{
|
|
||||||
Name = deckName,
|
|
||||||
Cards = new List<string>(deckCards),
|
|
||||||
Divers = new List<string>(divers),
|
|
||||||
Notes = deckNotes,
|
|
||||||
Season = deckSeason,
|
|
||||||
UpdatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
Db.UserDecks.Add(deck);
|
|
||||||
await Db.SaveChangesAsync();
|
|
||||||
saveMessage = "Saved!";
|
|
||||||
saveError = false;
|
|
||||||
Nav.NavigateTo($"/deck-builder/{deck.Id}", replace: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
saveMessage = $"Error: {ex.Message}";
|
|
||||||
saveError = true;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
isSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,815 +0,0 @@
|
|||||||
/* ── Top Bar ── */
|
|
||||||
.builder-topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.6rem 1.25rem;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-name-input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 180px;
|
|
||||||
max-width: 360px;
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-name-input:focus {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.season-input {
|
|
||||||
width: 110px;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.season-input:focus {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validity-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-valid {
|
|
||||||
background: rgba(102, 187, 106, 0.15);
|
|
||||||
color: #66bb6a;
|
|
||||||
border: 1px solid rgba(102, 187, 106, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-invalid {
|
|
||||||
background: rgba(255, 171, 64, 0.12);
|
|
||||||
color: #ffab40;
|
|
||||||
border: 1px solid rgba(255, 171, 64, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-toast {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
animation: fade-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-ok {
|
|
||||||
color: #66bb6a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-error {
|
|
||||||
color: #ef5350;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main Layout ── */
|
|
||||||
.builder-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 380px;
|
|
||||||
height: calc(100vh - 110px);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Browser Panel ── */
|
|
||||||
.browser-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Category Tabs ── */
|
|
||||||
.browser-panel .category-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.75rem 1rem 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.35rem 0.85rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
border-color: rgba(108, 99, 255, 0.45);
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: rgba(108, 99, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 2px 10px rgba(108, 99, 255, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Filter Bar ── */
|
|
||||||
.browser-panel .filter-bar {
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrapper {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.6rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
padding-left: 2rem;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.4rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.15rem 0.3rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-clear:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-select {
|
|
||||||
width: 130px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-select:focus {
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Column Picker ── */
|
|
||||||
.col-picker {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-btn {
|
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-btn.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Browser Grid ── */
|
|
||||||
.browser-grid {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.75rem 1rem 1rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(var(--grid-cols, 4), 1fr);
|
|
||||||
column-gap: 0.6rem;
|
|
||||||
row-gap: 1rem;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
|
||||||
height: 210px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card:hover {
|
|
||||||
transform: translateY(-4px) scale(1.01);
|
|
||||||
box-shadow: 0 8px 24px rgba(108, 99, 255, 0.28);
|
|
||||||
border-color: rgba(108, 99, 255, 0.45);
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card.in-deck {
|
|
||||||
border-color: rgba(108, 99, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card.maxed {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card.maxed:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card-img {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: calc(100% - 26px);
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0e0e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card-img img {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card:hover .browser-card-img img {
|
|
||||||
transform: scale(1.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.in-deck-count {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0.3rem;
|
|
||||||
right: 0.3rem;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 700;
|
|
||||||
width: 1.35rem;
|
|
||||||
height: 1.35rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.maxed-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-card-name {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0 0.45rem;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
height: 26px;
|
|
||||||
line-height: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Deck Panel ── */
|
|
||||||
.deck-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-status-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-count {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-denom {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-ok {
|
|
||||||
color: #66bb6a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-over {
|
|
||||||
color: #ef5350;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-under {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mana Curve ── */
|
|
||||||
.mana-curve {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 0.75rem 1rem 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
height: 80px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.curve-col {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curve-bar-wrap {
|
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curve-count {
|
|
||||||
font-size: 0.58rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
height: 0.8rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curve-bar {
|
|
||||||
width: 80%;
|
|
||||||
background: linear-gradient(to top, var(--accent), rgba(108, 99, 255, 0.5));
|
|
||||||
border-radius: 2px 2px 0 0;
|
|
||||||
min-height: 2px;
|
|
||||||
transition: height 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curve-label {
|
|
||||||
font-size: 0.58rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Deck Card List ── */
|
|
||||||
.deck-card-list {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.4rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 2.5rem 1rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-empty i {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
transition: background 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-row:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-row-img {
|
|
||||||
width: 26px;
|
|
||||||
height: 37px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-row-name {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-row-cost {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--gold);
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-row-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-btn {
|
|
||||||
width: 1.4rem;
|
|
||||||
height: 1.4rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-btn:hover:not(:disabled) {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-btn:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-display {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 700;
|
|
||||||
width: 1.1rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Divers Section ── */
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.55rem 0.75rem 0.4rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-count {
|
|
||||||
margin-left: auto;
|
|
||||||
background: var(--bg-hover);
|
|
||||||
padding: 0.1rem 0.5rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divers-section {
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-slot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin: 0.25rem 0.75rem;
|
|
||||||
padding: 0.45rem 0.65rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px dashed var(--border);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.83rem;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-slot.empty:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--accent);
|
|
||||||
background: rgba(108, 99, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-slot.filled {
|
|
||||||
border-style: solid;
|
|
||||||
border-color: rgba(108, 99, 255, 0.35);
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-slot.filled:hover {
|
|
||||||
border-color: rgba(108, 99, 255, 0.6);
|
|
||||||
box-shadow: 0 2px 8px rgba(108, 99, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-thumb {
|
|
||||||
width: 26px;
|
|
||||||
height: 37px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 3px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-name {
|
|
||||||
flex: 1;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.83rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-remove {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-remove:hover {
|
|
||||||
color: #ef5350;
|
|
||||||
background: rgba(239, 83, 80, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Notes Section ── */
|
|
||||||
.notes-section {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes-input {
|
|
||||||
margin: 0 0.75rem;
|
|
||||||
width: calc(100% - 1.5rem);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-color: var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notes-input:focus {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Diver Picker Modal ── */
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
z-index: 1040;
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 1050;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
width: 580px;
|
|
||||||
max-width: 93vw;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(108, 99, 255, 0.1);
|
|
||||||
animation: modal-enter 0.18s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modal-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -52%) scale(0.97);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translate(-50%, -50%) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-modal-header h5 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-picker-grid {
|
|
||||||
overflow-y: auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-picker-card {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-picker-card:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 6px 16px rgba(108, 99, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-picker-card img {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 5 / 7;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-picker-card span {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.67rem;
|
|
||||||
padding: 0.25rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.builder-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-panel {
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
max-height: 70vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-panel {
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.builder-topbar {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Force single column on mobile regardless of picker selection */
|
|
||||||
.browser-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-picker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@page "/test"
|
|
||||||
<h1>Test Page</h1>
|
|
||||||
<p>This is a test page.</p>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
@page "/login"
|
|
||||||
@rendermode InteractiveServer
|
|
||||||
@attribute [AllowAnonymous]
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
|
||||||
|
|
||||||
<PageTitle>Sign In — Chrono CCG</PageTitle>
|
|
||||||
|
|
||||||
<div class="login-container">
|
|
||||||
<div class="login-card">
|
|
||||||
<h1 class="login-title">Chrono CCG</h1>
|
|
||||||
|
|
||||||
@if (_error is not null)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">@_error</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (_enrolled)
|
|
||||||
{
|
|
||||||
<p class="login-hint">Use your registered passkey to sign in.</p>
|
|
||||||
<button class="btn btn-primary btn-lg w-100" @onclick="SignIn" disabled="@_busy">
|
|
||||||
@if (_busy)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
||||||
}
|
|
||||||
Sign in with Passkey
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="login-hint">No passkey enrolled yet. Register your device to get started.</p>
|
|
||||||
<button class="btn btn-success btn-lg w-100" @onclick="Enroll" disabled="@_busy">
|
|
||||||
@if (_busy)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
||||||
}
|
|
||||||
Enroll Passkey
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool _enrolled;
|
|
||||||
private bool _busy;
|
|
||||||
private string? _error;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
if (authState.User.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
Nav.NavigateTo("/", replace: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var http = new HttpClient { BaseAddress = new Uri(Nav.BaseUri) };
|
|
||||||
var status = await http.GetFromJsonAsync<StatusResponse>("/api/auth/status");
|
|
||||||
_enrolled = status?.Enrolled ?? false;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
_enrolled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SignIn()
|
|
||||||
{
|
|
||||||
_busy = true;
|
|
||||||
_error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("passkeyLogin");
|
|
||||||
Nav.NavigateTo("/", true);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Enroll()
|
|
||||||
{
|
|
||||||
_busy = true;
|
|
||||||
_error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("passkeyEnroll");
|
|
||||||
Nav.NavigateTo("/", true);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_error = ex.Message;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private record StatusResponse(bool Enrolled, bool Authenticated);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.login-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bs-body-bg, #f8f9fa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.10);
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 380px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-hint {
|
|
||||||
color: #6c757d;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
@page "/my-decks"
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject AppDbContext Db
|
|
||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
<PageTitle>My Decks - Chrono CCG</PageTitle>
|
|
||||||
|
|
||||||
<div class="my-decks-container">
|
|
||||||
<div class="my-decks-header">
|
|
||||||
<div>
|
|
||||||
<h1>My Decks</h1>
|
|
||||||
<p class="text-secondary">@decks.Count deck@(decks.Count != 1 ? "s" : "") saved</p>
|
|
||||||
</div>
|
|
||||||
<a href="/deck-builder" class="btn btn-primary">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i> New Deck
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (loading)
|
|
||||||
{
|
|
||||||
<div class="decks-loading">
|
|
||||||
<span class="spinner-border spinner-border-sm me-2"></span> Loading decks...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (decks.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="decks-empty">
|
|
||||||
<i class="bi bi-journal-x"></i>
|
|
||||||
<p>No decks yet. Create your first deck!</p>
|
|
||||||
<a href="/deck-builder" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i> Build a Deck
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="decks-grid">
|
|
||||||
@foreach (var deck in decks)
|
|
||||||
{
|
|
||||||
var cardCount = deck.Cards.Count;
|
|
||||||
var uniqueCount = deck.Cards.Distinct().Count();
|
|
||||||
var isValid = cardCount == 40 && deck.Divers.Count == 2;
|
|
||||||
<div class="deck-card" @onclick="() => EditDeck(deck.Id)">
|
|
||||||
<div class="deck-card-header">
|
|
||||||
<div class="deck-name">@(string.IsNullOrEmpty(deck.Name) ? "Untitled Deck" : deck.Name)</div>
|
|
||||||
<span class="deck-valid-badge @(isValid ? "valid" : "invalid")">
|
|
||||||
<i class="bi @(isValid ? "bi-check-circle-fill" : "bi-exclamation-circle-fill")"></i>
|
|
||||||
@(isValid ? "Valid" : $"{cardCount}/40")
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(deck.Season))
|
|
||||||
{
|
|
||||||
<div class="deck-season">
|
|
||||||
<i class="bi bi-calendar3"></i> @deck.Season
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="deck-meta">
|
|
||||||
<span><i class="bi bi-stack"></i> @cardCount cards</span>
|
|
||||||
<span><i class="bi bi-files"></i> @uniqueCount unique</span>
|
|
||||||
<span><i class="bi bi-person-arms-up"></i> @deck.Divers.Count/2 divers</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (deck.Divers.Count > 0)
|
|
||||||
{
|
|
||||||
<div class="deck-divers">
|
|
||||||
@foreach (var diver in deck.Divers)
|
|
||||||
{
|
|
||||||
<span class="diver-chip"><i class="bi bi-person-arms-up"></i> @diver</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(deck.Notes))
|
|
||||||
{
|
|
||||||
<div class="deck-notes-preview">@deck.Notes</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="deck-footer">
|
|
||||||
<span class="deck-updated">
|
|
||||||
<i class="bi bi-clock"></i> @deck.UpdatedAt.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-outline-danger deck-delete"
|
|
||||||
@onclick:stopPropagation="true"
|
|
||||||
@onclick="() => ConfirmDelete(deck)">
|
|
||||||
<i class="bi bi-trash3"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (deckToDelete != null)
|
|
||||||
{
|
|
||||||
<div class="modal-backdrop" @onclick="CancelDelete"></div>
|
|
||||||
<div class="confirm-modal">
|
|
||||||
<h5>Delete Deck</h5>
|
|
||||||
<p>Delete <strong>@(string.IsNullOrEmpty(deckToDelete.Name) ? "Untitled Deck" : deckToDelete.Name)</strong>?
|
|
||||||
This cannot be undone.</p>
|
|
||||||
<div class="d-flex gap-2 justify-content-end">
|
|
||||||
<button class="btn btn-outline-secondary" @onclick="CancelDelete">Cancel</button>
|
|
||||||
<button class="btn btn-danger" @onclick="DeleteDeck" disabled="@deleting">
|
|
||||||
@if (deleting)
|
|
||||||
{
|
|
||||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
|
||||||
}
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<UserDeck> decks = [];
|
|
||||||
private bool loading = true;
|
|
||||||
private UserDeck? deckToDelete;
|
|
||||||
private bool deleting;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await LoadDecks();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadDecks()
|
|
||||||
{
|
|
||||||
loading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
decks = await Db.UserDecks.OrderByDescending(d => d.UpdatedAt).ToListAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not load decks: {ex.Message}");
|
|
||||||
decks = [];
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EditDeck(Guid id)
|
|
||||||
{
|
|
||||||
Nav.NavigateTo($"/deck-builder/{id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConfirmDelete(UserDeck deck)
|
|
||||||
{
|
|
||||||
deckToDelete = deck;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelDelete()
|
|
||||||
{
|
|
||||||
deckToDelete = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteDeck()
|
|
||||||
{
|
|
||||||
if (deckToDelete == null) return;
|
|
||||||
deleting = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var deck = await Db.UserDecks.FindAsync(deckToDelete.Id);
|
|
||||||
if (deck != null)
|
|
||||||
{
|
|
||||||
Db.UserDecks.Remove(deck);
|
|
||||||
await Db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not delete deck: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
deleting = false;
|
|
||||||
deckToDelete = null;
|
|
||||||
await LoadDecks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
.my-decks-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-decks-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-decks-header h1 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-decks-header p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decks-loading {
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding: 2rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decks-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 1rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decks-empty i {
|
|
||||||
font-size: 3rem;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decks-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-card {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-card:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 6px 24px rgba(108, 99, 255, 0.15);
|
|
||||||
border-color: rgba(108, 99, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-card-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-valid-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
border-radius: 100px;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-valid-badge.valid {
|
|
||||||
background: rgba(102, 187, 106, 0.15);
|
|
||||||
color: #66bb6a;
|
|
||||||
border: 1px solid rgba(102, 187, 106, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-valid-badge.invalid {
|
|
||||||
background: rgba(255, 171, 64, 0.12);
|
|
||||||
color: #ffab40;
|
|
||||||
border: 1px solid rgba(255, 171, 64, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-season {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-meta span {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-divers {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diver-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.2rem 0.55rem;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 100px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-notes-preview {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.5;
|
|
||||||
background: var(--bg-elevated);
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border-left: 2px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 0.4rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-updated {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-delete {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deck-card:hover .deck-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Confirm Modal ── */
|
|
||||||
.confirm-modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 1050;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.5rem;
|
|
||||||
width: 380px;
|
|
||||||
max-width: 92vw;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
|
||||||
animation: detail-enter 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-modal h5 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-modal p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
@inject NavigationManager Nav
|
|
||||||
|
|
||||||
@code {
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
Nav.NavigateTo("/login", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<Router AppAssembly="@typeof(Program).Assembly"
|
|
||||||
AdditionalAssemblies="new[] { typeof(Home).Assembly, typeof(Syndicates).Assembly, typeof(Agents).Assembly }">
|
|
||||||
<Found Context="routeData">
|
|
||||||
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
|
|
||||||
<NotAuthorized>
|
|
||||||
<RedirectToLogin/>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeRouteView>
|
|
||||||
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
|
||||||
</Found>
|
|
||||||
</Router>
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Cloud.Models;
|
|
||||||
using Fido2NetLib;
|
|
||||||
using Fido2NetLib.Objects;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Cloud.Controllers;
|
|
||||||
|
|
||||||
[AllowAnonymous]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/auth")]
|
|
||||||
public class AuthController : ControllerBase
|
|
||||||
{
|
|
||||||
private const string RegOptionsKey = "fido2.reg.options";
|
|
||||||
private const string AssertOptionsKey = "fido2.assert.options";
|
|
||||||
private readonly AppDbContext _db;
|
|
||||||
|
|
||||||
private readonly IFido2 _fido2;
|
|
||||||
|
|
||||||
public AuthController(IFido2 fido2, AppDbContext db)
|
|
||||||
{
|
|
||||||
_fido2 = fido2;
|
|
||||||
_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registration ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[HttpPost("register/options")]
|
|
||||||
public async Task<IActionResult> RegisterOptions()
|
|
||||||
{
|
|
||||||
// Only allow registration when unenrolled or already authenticated
|
|
||||||
var hasCredentials = await _db.PasskeyCredentials.AnyAsync();
|
|
||||||
if (hasCredentials && !User.Identity!.IsAuthenticated)
|
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
var user = new Fido2User
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToByteArray(),
|
|
||||||
Name = "owner",
|
|
||||||
DisplayName = "Owner"
|
|
||||||
};
|
|
||||||
|
|
||||||
var existingKeys = await _db.PasskeyCredentials
|
|
||||||
.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var options = _fido2.RequestNewCredential(new RequestNewCredentialParams
|
|
||||||
{
|
|
||||||
User = user,
|
|
||||||
ExcludeCredentials = existingKeys,
|
|
||||||
AuthenticatorSelection = new AuthenticatorSelection
|
|
||||||
{
|
|
||||||
ResidentKey = ResidentKeyRequirement.Required,
|
|
||||||
UserVerification = UserVerificationRequirement.Required
|
|
||||||
},
|
|
||||||
AttestationPreference = AttestationConveyancePreference.None
|
|
||||||
});
|
|
||||||
|
|
||||||
HttpContext.Session.SetString(RegOptionsKey, options.ToJson());
|
|
||||||
return Ok(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("register/complete")]
|
|
||||||
public async Task<IActionResult> RegisterComplete(
|
|
||||||
[FromBody] AuthenticatorAttestationRawResponse attestationResponse)
|
|
||||||
{
|
|
||||||
var json = HttpContext.Session.GetString(RegOptionsKey);
|
|
||||||
if (json is null) return BadRequest("Session expired. Please retry enrollment.");
|
|
||||||
|
|
||||||
var options = CredentialCreateOptions.FromJson(json);
|
|
||||||
HttpContext.Session.Remove(RegOptionsKey);
|
|
||||||
|
|
||||||
var hasCredentials = await _db.PasskeyCredentials.AnyAsync();
|
|
||||||
if (hasCredentials && !User.Identity!.IsAuthenticated)
|
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
IsCredentialIdUniqueToUserAsyncDelegate isUnique = async (args, _) =>
|
|
||||||
!await _db.PasskeyCredentials.AnyAsync(c => c.CredentialId == args.CredentialId);
|
|
||||||
|
|
||||||
var result = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
|
|
||||||
{
|
|
||||||
AttestationResponse = attestationResponse,
|
|
||||||
OriginalOptions = options,
|
|
||||||
IsCredentialIdUniqueToUserCallback = isUnique
|
|
||||||
});
|
|
||||||
|
|
||||||
var credential = new PasskeyCredential
|
|
||||||
{
|
|
||||||
CredentialId = result.Id,
|
|
||||||
PublicKey = result.PublicKey,
|
|
||||||
SignCount = result.SignCount,
|
|
||||||
UserHandle = result.User.Id,
|
|
||||||
AaGuid = result.AaGuid.ToString()
|
|
||||||
};
|
|
||||||
_db.PasskeyCredentials.Add(credential);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var claims = new[] { new Claim(ClaimTypes.Name, "owner") };
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
await HttpContext.SignInAsync(
|
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
||||||
new ClaimsPrincipal(identity),
|
|
||||||
new AuthenticationProperties { IsPersistent = true });
|
|
||||||
|
|
||||||
return Ok(new { message = "Passkey enrolled and authenticated." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Authentication ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[HttpPost("login/options")]
|
|
||||||
public async Task<IActionResult> LoginOptions()
|
|
||||||
{
|
|
||||||
var existingKeys = await _db.PasskeyCredentials
|
|
||||||
.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
|
||||||
{
|
|
||||||
AllowedCredentials = existingKeys,
|
|
||||||
UserVerification = UserVerificationRequirement.Required
|
|
||||||
});
|
|
||||||
|
|
||||||
HttpContext.Session.SetString(AssertOptionsKey, options.ToJson());
|
|
||||||
return Ok(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("login/complete")]
|
|
||||||
public async Task<IActionResult> LoginComplete([FromBody] AuthenticatorAssertionRawResponse assertionResponse)
|
|
||||||
{
|
|
||||||
var json = HttpContext.Session.GetString(AssertOptionsKey);
|
|
||||||
if (json is null) return BadRequest("Session expired. Please retry sign-in.");
|
|
||||||
|
|
||||||
var options = AssertionOptions.FromJson(json);
|
|
||||||
HttpContext.Session.Remove(AssertOptionsKey);
|
|
||||||
|
|
||||||
// assertionResponse.RawId contains the credential ID as raw bytes
|
|
||||||
var credentialId = assertionResponse.RawId;
|
|
||||||
var allCredentials = await _db.PasskeyCredentials.ToListAsync();
|
|
||||||
var stored = allCredentials.FirstOrDefault(c => c.CredentialId.SequenceEqual(credentialId));
|
|
||||||
|
|
||||||
if (stored is null) return Unauthorized("Credential not recognized.");
|
|
||||||
|
|
||||||
IsUserHandleOwnerOfCredentialIdAsync isOwner = (args, _) =>
|
|
||||||
Task.FromResult(args.UserHandle.SequenceEqual(stored.UserHandle));
|
|
||||||
|
|
||||||
var result = await _fido2.MakeAssertionAsync(new MakeAssertionParams
|
|
||||||
{
|
|
||||||
AssertionResponse = assertionResponse,
|
|
||||||
OriginalOptions = options,
|
|
||||||
StoredPublicKey = stored.PublicKey,
|
|
||||||
StoredSignatureCounter = (uint)stored.SignCount,
|
|
||||||
IsUserHandleOwnerOfCredentialIdCallback = isOwner
|
|
||||||
});
|
|
||||||
|
|
||||||
stored.SignCount = result.SignCount;
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
var claims = new[] { new Claim(ClaimTypes.Name, "owner") };
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
await HttpContext.SignInAsync(
|
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
||||||
new ClaimsPrincipal(identity),
|
|
||||||
new AuthenticationProperties { IsPersistent = true });
|
|
||||||
|
|
||||||
return Ok(new { message = "Authenticated." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Logout ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[HttpPost("logout")]
|
|
||||||
public async Task<IActionResult> Logout()
|
|
||||||
{
|
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[HttpGet("status")]
|
|
||||||
public async Task<IActionResult> Status()
|
|
||||||
{
|
|
||||||
var hasCredentials = await _db.PasskeyCredentials.AnyAsync();
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
enrolled = hasCredentials,
|
|
||||||
authenticated = User.Identity?.IsAuthenticated ?? false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dev-only test helpers (Development environment only) ──────────────────
|
|
||||||
|
|
||||||
[HttpPost("dev-login")]
|
|
||||||
public async Task<IActionResult> DevLogin([FromServices] IWebHostEnvironment env)
|
|
||||||
{
|
|
||||||
if (!env.IsDevelopment()) return NotFound();
|
|
||||||
|
|
||||||
var claims = new[] { new Claim(ClaimTypes.Name, "owner") };
|
|
||||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
await HttpContext.SignInAsync(
|
|
||||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
||||||
new ClaimsPrincipal(identity),
|
|
||||||
new AuthenticationProperties { IsPersistent = false });
|
|
||||||
|
|
||||||
return Ok(new { message = "Dev login successful." });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("credentials")]
|
|
||||||
public async Task<IActionResult> DeleteAllCredentials([FromServices] IWebHostEnvironment env)
|
|
||||||
{
|
|
||||||
if (!env.IsDevelopment()) return NotFound();
|
|
||||||
|
|
||||||
_db.PasskeyCredentials.RemoveRange(_db.PasskeyCredentials);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { message = "All credentials deleted." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
using Cloud.Models;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Cloud.Controllers;
|
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class DecksController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public DecksController(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<ActionResult<List<UserDeck>>> GetDecks()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Ok(await _context.UserDecks.OrderByDescending(d => d.UpdatedAt).ToListAsync());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not retrieve decks: {ex.Message}");
|
|
||||||
return Ok(new List<UserDeck>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
|
||||||
public async Task<ActionResult<UserDeck>> GetDeck(Guid id)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var deck = await _context.UserDecks.FindAsync(id);
|
|
||||||
if (deck == null) return NotFound();
|
|
||||||
return Ok(deck);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not retrieve deck: {ex.Message}");
|
|
||||||
return StatusCode(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<ActionResult<UserDeck>> CreateDeck(UserDeck deck)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
deck.Id = Guid.NewGuid();
|
|
||||||
deck.UpdatedAt = DateTime.UtcNow;
|
|
||||||
_context.UserDecks.Add(deck);
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
return Ok(deck);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not create deck: {ex.Message}");
|
|
||||||
return StatusCode(500, "Failed to save deck.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut("{id:guid}")]
|
|
||||||
public async Task<ActionResult<UserDeck>> UpdateDeck(Guid id, UserDeck deck)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var existing = await _context.UserDecks.FindAsync(id);
|
|
||||||
if (existing == null) return NotFound();
|
|
||||||
|
|
||||||
existing.Name = deck.Name;
|
|
||||||
existing.Cards = deck.Cards;
|
|
||||||
existing.Divers = deck.Divers;
|
|
||||||
existing.Notes = deck.Notes;
|
|
||||||
existing.Season = deck.Season;
|
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
return Ok(existing);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not update deck: {ex.Message}");
|
|
||||||
return StatusCode(500, "Failed to update deck.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
|
||||||
public async Task<IActionResult> DeleteDeck(Guid id)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var deck = await _context.UserDecks.FindAsync(id);
|
|
||||||
if (deck == null) return NotFound();
|
|
||||||
_context.UserDecks.Remove(deck);
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not delete deck: {ex.Message}");
|
|
||||||
return StatusCode(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Model;
|
|
||||||
|
|
||||||
namespace Cloud.Controllers;
|
|
||||||
|
|
||||||
[Authorize]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class NotesController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _context;
|
|
||||||
|
|
||||||
public NotesController(AppDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{cardName}")]
|
|
||||||
public async Task<ActionResult<CardNote>> GetNote(string cardName)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var note = await _context.CardNotes.FindAsync(cardName);
|
|
||||||
if (note == null) return Ok(new CardNote { CardName = cardName, Note = "" });
|
|
||||||
return Ok(note);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not retrieve note from database: {ex.Message}");
|
|
||||||
return Ok(new CardNote { CardName = cardName, Note = "" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<ActionResult<CardNote>> UpdateNote(CardNote note)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var existing = await _context.CardNotes.FindAsync(note.CardName);
|
|
||||||
if (existing == null)
|
|
||||||
_context.CardNotes.Add(note);
|
|
||||||
else
|
|
||||||
existing.Note = note.Note;
|
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"[WARNING] Could not save note to database: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(note);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using Cloud;
|
|
||||||
using Cloud.Components;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Shared.Pages;
|
|
||||||
using Shared.Services;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
|
||||||
builder.Services.AddTelerikBlazor();
|
|
||||||
builder.Services.AddSingleton<CardRenderingService>();
|
|
||||||
builder.Services.AddScoped<CardPreviewService>();
|
|
||||||
builder.Services.AddScoped<AnalyticsService>();
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
|
|
||||||
builder.Services.AddDistributedMemoryCache();
|
|
||||||
builder.Services.AddSession(options =>
|
|
||||||
{
|
|
||||||
options.IdleTimeout = TimeSpan.FromMinutes(5);
|
|
||||||
options.Cookie.HttpOnly = true;
|
|
||||||
options.Cookie.IsEssential = true;
|
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
||||||
.AddCookie(options =>
|
|
||||||
{
|
|
||||||
options.LoginPath = "/login";
|
|
||||||
options.LogoutPath = "/logout";
|
|
||||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
|
||||||
options.SlidingExpiration = true;
|
|
||||||
options.Cookie.HttpOnly = true;
|
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
||||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
|
||||||
});
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
builder.Services.AddFido2(options =>
|
|
||||||
{
|
|
||||||
options.ServerDomain = builder.Configuration["Fido2:ServerDomain"]!;
|
|
||||||
options.ServerName = builder.Configuration["Fido2:ServerName"]!;
|
|
||||||
options.Origins = builder.Configuration.GetSection("Fido2:Origins").Get<HashSet<string>>()!;
|
|
||||||
options.TimestampDriftTolerance = 300_000;
|
|
||||||
});
|
|
||||||
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(connectionString)) options.UseNpgsql(connectionString);
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
if (!string.IsNullOrEmpty(connectionString)) db.Database.Migrate();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine(
|
|
||||||
$"[WARNING] Database migration failed: {ex.Message}. The application will continue without a database connection.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment()) app.UseExceptionHandler("/Error");
|
|
||||||
|
|
||||||
app.UseStaticFiles();
|
|
||||||
app.UseSession();
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseAntiforgery();
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
app.MapRazorComponents<App>()
|
|
||||||
.AddInteractiveServerRenderMode()
|
|
||||||
.AddAdditionalAssemblies(typeof(Home).Assembly);
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "http://localhost:5256",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": false,
|
|
||||||
"applicationUrl": "https://localhost:7266;http://localhost:5256",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@Server_HostAddress = http://localhost:5256
|
|
||||||
|
|
||||||
GET {{Server_HostAddress}}/weatherforecast/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"Fido2": {
|
|
||||||
"ServerDomain": "localhost",
|
|
||||||
"ServerName": "Chrono CCG",
|
|
||||||
"Origins": [
|
|
||||||
"https://localhost:7001",
|
|
||||||
"http://localhost:5000"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// WebAuthn helpers — base64url encoding compatible with Fido2NetLib
|
|
||||||
|
|
||||||
function base64urlToBuffer(base64url) {
|
|
||||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const binary = atob(base64);
|
|
||||||
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bufferToBase64url(buffer) {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = '';
|
|
||||||
for (const b of bytes) binary += String.fromCharCode(b);
|
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareCreationOptions(options) {
|
|
||||||
options.challenge = base64urlToBuffer(options.challenge);
|
|
||||||
options.user.id = base64urlToBuffer(options.user.id);
|
|
||||||
if (options.excludeCredentials) {
|
|
||||||
options.excludeCredentials = options.excludeCredentials.map(c => ({
|
|
||||||
...c,
|
|
||||||
id: base64urlToBuffer(c.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareRequestOptions(options) {
|
|
||||||
options.challenge = base64urlToBuffer(options.challenge);
|
|
||||||
if (options.allowCredentials) {
|
|
||||||
options.allowCredentials = options.allowCredentials.map(c => ({
|
|
||||||
...c,
|
|
||||||
id: base64urlToBuffer(c.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeAttestation(credential) {
|
|
||||||
return {
|
|
||||||
id: credential.id,
|
|
||||||
rawId: bufferToBase64url(credential.rawId),
|
|
||||||
type: credential.type,
|
|
||||||
response: {
|
|
||||||
attestationObject: bufferToBase64url(credential.response.attestationObject),
|
|
||||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
||||||
transports: credential.response.getTransports ? credential.response.getTransports() : [],
|
|
||||||
},
|
|
||||||
extensions: credential.getClientExtensionResults(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeAssertion(credential) {
|
|
||||||
return {
|
|
||||||
id: credential.id,
|
|
||||||
rawId: bufferToBase64url(credential.rawId),
|
|
||||||
type: credential.type,
|
|
||||||
response: {
|
|
||||||
authenticatorData: bufferToBase64url(credential.response.authenticatorData),
|
|
||||||
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
||||||
signature: bufferToBase64url(credential.response.signature),
|
|
||||||
userHandle: credential.response.userHandle
|
|
||||||
? bufferToBase64url(credential.response.userHandle)
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
extensions: credential.getClientExtensionResults(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.passkeyEnroll = async function () {
|
|
||||||
const optRes = await fetch('/api/auth/register/options', {method: 'POST'});
|
|
||||||
if (!optRes.ok) throw new Error(await optRes.text());
|
|
||||||
const options = prepareCreationOptions(await optRes.json());
|
|
||||||
|
|
||||||
const credential = await navigator.credentials.create({publicKey: options});
|
|
||||||
|
|
||||||
const completeRes = await fetch('/api/auth/register/complete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(serializeAttestation(credential)),
|
|
||||||
});
|
|
||||||
if (!completeRes.ok) throw new Error(await completeRes.text());
|
|
||||||
return await completeRes.json();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.passkeyLogin = async function () {
|
|
||||||
const optRes = await fetch('/api/auth/login/options', {method: 'POST'});
|
|
||||||
if (!optRes.ok) throw new Error(await optRes.text());
|
|
||||||
const options = prepareRequestOptions(await optRes.json());
|
|
||||||
|
|
||||||
const credential = await navigator.credentials.get({publicKey: options});
|
|
||||||
|
|
||||||
const completeRes = await fetch('/api/auth/login/complete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(serializeAssertion(credential)),
|
|
||||||
});
|
|
||||||
if (!completeRes.ok) throw new Error(await completeRes.text());
|
|
||||||
return await completeRes.json();
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<base href="/"/>
|
||||||
|
<title>Chrono CCG - Database Test Hub</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"/>
|
||||||
|
<link rel="stylesheet" href="app.css"/>
|
||||||
|
<HeadOutlet/>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes/>
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="sidebar">
|
||||||
|
<NavMenu/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header class="top-navbar">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div class="status-pill @GetStatusClass()">
|
||||||
|
<span class="status-dot @GetStatusDotClass()"></span>
|
||||||
|
<span>@GetStatusText()</span>
|
||||||
|
</div>
|
||||||
|
@if (health?.IsConnected == true)
|
||||||
|
{
|
||||||
|
<span class="text-secondary small">
|
||||||
|
<i class="bi bi-clock-history"></i> @(health.ResponseTimeMs)ms latency
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<button class="btn btn-sm btn-chrono-secondary" @onclick="RefreshHealthAsync" title="Refresh Health Check">
|
||||||
|
<i class="bi bi-arrow-clockwise @(isChecking ? "spin" : "")"></i> Refresh
|
||||||
|
</button>
|
||||||
|
<a href="test-suite" class="btn btn-sm btn-chrono-primary">
|
||||||
|
<i class="bi bi-play-circle-fill"></i> Run Test Suite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article class="content-container">
|
||||||
|
@Body
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="blazor-error-ui" class="bg-danger text-white p-3 position-fixed bottom-0 start-0 end-0 d-none">
|
||||||
|
An unhandled error has occurred.
|
||||||
|
<a href="." class="text-white text-decoration-underline ms-2">Reload</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private DatabaseHealthStatus? health;
|
||||||
|
private bool isChecking;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
ConfigService.OnConfigurationChanged += HandleConfigChanged;
|
||||||
|
await RefreshHealthAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshHealthAsync()
|
||||||
|
{
|
||||||
|
isChecking = true;
|
||||||
|
StateHasChanged();
|
||||||
|
health = await DbService.CheckHealthAsync();
|
||||||
|
isChecking = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleConfigChanged()
|
||||||
|
{
|
||||||
|
_ = RefreshHealthAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetStatusClass()
|
||||||
|
{
|
||||||
|
if (health == null) return "status-inmemory";
|
||||||
|
if (!health.IsConnected) return "status-disconnected";
|
||||||
|
if (health.ProviderType == DatabaseProviderType.InMemory) return "status-inmemory";
|
||||||
|
return "status-connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetStatusDotClass()
|
||||||
|
{
|
||||||
|
if (health == null) return "purple";
|
||||||
|
if (!health.IsConnected) return "red";
|
||||||
|
if (health.ProviderType == DatabaseProviderType.InMemory) return "purple";
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetStatusText()
|
||||||
|
{
|
||||||
|
if (health == null) return "Checking connection...";
|
||||||
|
if (!health.IsConnected) return "Disconnected";
|
||||||
|
if (health.ProviderType == DatabaseProviderType.InMemory) return "In-Memory Active";
|
||||||
|
return "PostgreSQL Connected";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -75,3 +75,24 @@ main {
|
|||||||
padding-right: 1.5rem !important;
|
padding-right: 1.5rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui {
|
||||||
|
color-scheme: light only;
|
||||||
|
background: lightyellow;
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .dismiss {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<div class="nav-brand">
|
||||||
|
<i class="bi bi-database-fill-gear"></i>
|
||||||
|
<span>Chrono DB Hub</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-3 pt-3 pb-2">
|
||||||
|
<div class="p-2 rounded bg-dark border border-secondary border-opacity-25">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
|
<span class="text-secondary small fw-semibold">PROVIDER</span>
|
||||||
|
<span class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")" style="font-size: 0.72rem;">
|
||||||
|
@ConfigService.ProviderType
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-truncate text-light" style="font-size: 0.78rem;" title="@ConfigService.GetActiveProviderDescription()">
|
||||||
|
@(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "PostgreSQL Server" : "In-Memory Test DB")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-menu">
|
||||||
|
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||||
|
<i class="bi bi-speedometer2"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-link" href="card-notes">
|
||||||
|
<i class="bi bi-card-text"></i>
|
||||||
|
<span>Card Notes</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-link" href="user-decks">
|
||||||
|
<i class="bi bi-collection-play"></i>
|
||||||
|
<span>User Decks</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-link" href="passkeys">
|
||||||
|
<i class="bi bi-key-fill"></i>
|
||||||
|
<span>Passkeys</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-link" href="test-suite">
|
||||||
|
<i class="bi bi-clipboard2-check-fill"></i>
|
||||||
|
<span>Test Suite Runner</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink class="nav-link" href="diagnostics">
|
||||||
|
<i class="bi bi-sliders"></i>
|
||||||
|
<span>DB & Migrations</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<span>Chrono CCG Cloud DB</span>
|
||||||
|
<span class="badge bg-secondary">.NET 10</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
ConfigService.OnConfigurationChanged += HandleConfigChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleConfigChanged()
|
||||||
|
{
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
.navbar-toggler {
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
color: white;
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler:checked {
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
background-color: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
top: -1px;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-house-door-fill-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-plus-square-fill-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi-list-nested-nav-menu {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:first-of-type {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:last-of-type {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item ::deep .nav-link {
|
||||||
|
color: #d7d7d7;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 3rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item ::deep a.active {
|
||||||
|
background-color: rgba(255,255,255,0.37);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item ::deep .nav-link:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-scrollable {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggler:checked ~ .nav-scrollable {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
.navbar-toggler {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-scrollable {
|
||||||
|
/* Never collapse the sidebar for wide screens */
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
/* Allow sidebar to scroll for tall menus */
|
||||||
|
height: calc(100vh - 3.5rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||||
|
|
||||||
|
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||||
|
<div class="components-reconnect-container">
|
||||||
|
<div class="components-rejoining-animation" aria-hidden="true">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<p class="components-reconnect-first-attempt-visible">
|
||||||
|
Rejoining the server...
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-repeated-attempt-visible">
|
||||||
|
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||||
|
</p>
|
||||||
|
<p class="components-reconnect-failed-visible">
|
||||||
|
Failed to rejoin.<br/>Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<p class="components-pause-visible">
|
||||||
|
The session has been paused by the server.
|
||||||
|
</p>
|
||||||
|
<p class="components-resume-failed-visible">
|
||||||
|
Failed to resume the session.<br/>Please retry or reload the page.
|
||||||
|
</p>
|
||||||
|
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
.components-reconnect-first-attempt-visible,
|
||||||
|
.components-reconnect-repeated-attempt-visible,
|
||||||
|
.components-reconnect-failed-visible,
|
||||||
|
.components-pause-visible,
|
||||||
|
.components-resume-failed-visible,
|
||||||
|
.components-rejoining-animation {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||||
|
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-retrying,
|
||||||
|
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||||
|
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||||
|
#components-reconnect-modal.components-reconnect-failed,
|
||||||
|
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#components-reconnect-modal {
|
||||||
|
background-color: white;
|
||||||
|
width: 20rem;
|
||||||
|
margin: 20vh auto;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||||
|
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||||
|
&[open]
|
||||||
|
|
||||||
|
{
|
||||||
|
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal::backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-reconnect-modal-slideUp {
|
||||||
|
0% {
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-reconnect-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal p {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal button {
|
||||||
|
border: 0;
|
||||||
|
background-color: #6b9ed2;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal button:hover {
|
||||||
|
background-color: #3b6ea2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#components-reconnect-modal button:active {
|
||||||
|
background-color: #6b9ed2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-rejoining-animation {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-rejoining-animation div {
|
||||||
|
position: absolute;
|
||||||
|
border: 3px solid #0087ff;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.components-rejoining-animation div:nth-child(2) {
|
||||||
|
animation-delay: -0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes components-rejoining-animation {
|
||||||
|
0% {
|
||||||
|
top: 40px;
|
||||||
|
left: 40px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
4.9% {
|
||||||
|
top: 40px;
|
||||||
|
left: 40px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
5% {
|
||||||
|
top: 40px;
|
||||||
|
left: 40px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Set up event handlers
|
||||||
|
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||||
|
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||||
|
|
||||||
|
const retryButton = document.getElementById("components-reconnect-button");
|
||||||
|
retryButton.addEventListener("click", retry);
|
||||||
|
|
||||||
|
const resumeButton = document.getElementById("components-resume-button");
|
||||||
|
resumeButton.addEventListener("click", resume);
|
||||||
|
|
||||||
|
function handleReconnectStateChanged(event) {
|
||||||
|
if (event.detail.state === "show") {
|
||||||
|
reconnectModal.showModal();
|
||||||
|
} else if (event.detail.state === "hide") {
|
||||||
|
reconnectModal.close();
|
||||||
|
} else if (event.detail.state === "failed") {
|
||||||
|
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||||
|
} else if (event.detail.state === "rejected") {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reconnect will asynchronously return:
|
||||||
|
// - true to mean success
|
||||||
|
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||||
|
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||||
|
const successful = await Blazor.reconnect();
|
||||||
|
if (!successful) {
|
||||||
|
// We have been able to reach the server, but the circuit is no longer available.
|
||||||
|
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||||
|
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||||
|
if (!resumeSuccessful) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
reconnectModal.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// We got an exception, server is currently unavailable
|
||||||
|
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume() {
|
||||||
|
try {
|
||||||
|
const successful = await Blazor.resumeCircuit();
|
||||||
|
if (!successful) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryWhenDocumentBecomesVisible() {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
await retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
+11
-11
@@ -1,19 +1,19 @@
|
|||||||
@using System.Net.Http
|
@using System.Net.Http
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@using Microsoft.AspNetCore.Authorization
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@using Model
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using Shared.Pages
|
@using DatabaseTest
|
||||||
@using Shared.Components
|
@using DatabaseTest.Components
|
||||||
@using Cloud.Components
|
@using DatabaseTest.Components.Layout
|
||||||
|
@using DatabaseTest.Models
|
||||||
|
@using DatabaseTest.Services
|
||||||
@using Cloud.Models
|
@using Cloud.Models
|
||||||
@using Telerik.Blazor
|
@using Model
|
||||||
@using Telerik.Blazor.Components
|
|
||||||
@using Telerik.DataSource
|
@inject IDatabaseConfigService ConfigService
|
||||||
@using Cloud.Components.Layout
|
@inject IDatabaseService DbService
|
||||||
|
@inject NavigationManager Navigation
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Model\Model.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\Shared\Generated\Cards.g.cs" Link="Generated\Cards.g.cs" />
|
||||||
|
<Compile Include="..\Shared\Generated\Decks.g.cs" Link="Generated\Decks.g.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace DatabaseTest.Models;
|
||||||
|
|
||||||
|
public class DatabaseHealthStatus
|
||||||
|
{
|
||||||
|
public bool IsConnected { get; set; }
|
||||||
|
public DatabaseProviderType ProviderType { get; set; }
|
||||||
|
public string ProviderName { get; set; } = string.Empty;
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
public long ResponseTimeMs { get; set; }
|
||||||
|
public int CardNotesCount { get; set; }
|
||||||
|
public int UserDecksCount { get; set; }
|
||||||
|
public int PasskeysCount { get; set; }
|
||||||
|
public int PendingMigrationsCount { get; set; }
|
||||||
|
public int AppliedMigrationsCount { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime CheckedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class OperationResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
public string? ErrorDetails { get; set; }
|
||||||
|
|
||||||
|
public static OperationResult Ok(string message) => new() { Success = true, Message = message };
|
||||||
|
public static OperationResult Fail(string message, string? errorDetails = null) => new()
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = message,
|
||||||
|
ErrorDetails = errorDetails
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SeedResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public int CardNotesSeeded { get; set; }
|
||||||
|
public int UserDecksSeeded { get; set; }
|
||||||
|
public int PasskeysSeeded { get; set; }
|
||||||
|
public string Message { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MigrationReport
|
||||||
|
{
|
||||||
|
public DatabaseProviderType ProviderType { get; set; }
|
||||||
|
public List<string> AppliedMigrations { get; set; } = [];
|
||||||
|
public List<string> PendingMigrations { get; set; } = [];
|
||||||
|
public bool SupportsMigrations { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TestStatus
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Running,
|
||||||
|
Passed,
|
||||||
|
Failed,
|
||||||
|
Skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DatabaseTestCaseResult
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public TestStatus Status { get; set; } = TestStatus.Pending;
|
||||||
|
public long DurationMs { get; set; }
|
||||||
|
public List<string> Logs { get; set; } = [];
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string? StackTrace { get; set; }
|
||||||
|
|
||||||
|
public void AddLog(string message)
|
||||||
|
{
|
||||||
|
Logs.Add($"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace DatabaseTest.Models;
|
||||||
|
|
||||||
|
public enum DatabaseProviderType
|
||||||
|
{
|
||||||
|
PostgreSQL,
|
||||||
|
InMemory
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using DatabaseTest.Components;
|
||||||
|
using DatabaseTest.Services;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IDatabaseConfigService, DatabaseConfigService>();
|
||||||
|
builder.Services.AddScoped<IDatabaseService, DatabaseService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
app.MapStaticAssets();
|
||||||
|
app.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5229",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7035;http://localhost:5229",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Cloud;
|
||||||
|
using DatabaseTest.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cloud.Models;
|
||||||
|
|
||||||
|
namespace DatabaseTest.Services;
|
||||||
|
|
||||||
|
public interface IDatabaseConfigService
|
||||||
|
{
|
||||||
|
DatabaseProviderType ProviderType { get; }
|
||||||
|
string ConnectionString { get; }
|
||||||
|
string InMemoryDbName { get; }
|
||||||
|
event Action? OnConfigurationChanged;
|
||||||
|
|
||||||
|
void SetProvider(DatabaseProviderType providerType);
|
||||||
|
void SetConnectionString(string connectionString);
|
||||||
|
void SetInMemoryDbName(string name);
|
||||||
|
AppDbContext CreateDbContext();
|
||||||
|
string GetActiveProviderDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DatabaseConfigService : IDatabaseConfigService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private DatabaseProviderType _providerType;
|
||||||
|
private string _connectionString;
|
||||||
|
private string _inMemoryDbName = "chrono_database_test";
|
||||||
|
|
||||||
|
public event Action? OnConfigurationChanged;
|
||||||
|
|
||||||
|
public DatabaseConfigService(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
|
||||||
|
var configuredConn = _configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? _configuration.GetConnectionString("Postgres")
|
||||||
|
?? Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
|
||||||
|
?? "Host=localhost;Port=5432;Database=chrono;Username=postgres;Password=postgres";
|
||||||
|
|
||||||
|
_connectionString = configuredConn;
|
||||||
|
_providerType = DatabaseProviderType.PostgreSQL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DatabaseProviderType ProviderType => _providerType;
|
||||||
|
public string ConnectionString => _connectionString;
|
||||||
|
public string InMemoryDbName => _inMemoryDbName;
|
||||||
|
|
||||||
|
public void SetProvider(DatabaseProviderType providerType)
|
||||||
|
{
|
||||||
|
if (_providerType != providerType)
|
||||||
|
{
|
||||||
|
_providerType = providerType;
|
||||||
|
OnConfigurationChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetConnectionString(string connectionString)
|
||||||
|
{
|
||||||
|
if (_connectionString != connectionString)
|
||||||
|
{
|
||||||
|
_connectionString = connectionString;
|
||||||
|
OnConfigurationChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetInMemoryDbName(string name)
|
||||||
|
{
|
||||||
|
if (_inMemoryDbName != name)
|
||||||
|
{
|
||||||
|
_inMemoryDbName = name;
|
||||||
|
OnConfigurationChanged?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetActiveProviderDescription()
|
||||||
|
{
|
||||||
|
return _providerType switch
|
||||||
|
{
|
||||||
|
DatabaseProviderType.PostgreSQL => $"PostgreSQL ({GetMaskedConnectionString(_connectionString)})",
|
||||||
|
DatabaseProviderType.InMemory => $"In-Memory Test Database ({_inMemoryDbName})",
|
||||||
|
_ => _providerType.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMaskedConnectionString(string conn)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(conn)) return "Not configured";
|
||||||
|
// Hide password in display
|
||||||
|
return System.Text.RegularExpressions.Regex.Replace(conn, @"(?i)Password=[^;]+", "Password=******");
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext CreateDbContext()
|
||||||
|
{
|
||||||
|
var builder = new DbContextOptionsBuilder<AppDbContext>();
|
||||||
|
|
||||||
|
if (_providerType == DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
builder.UseNpgsql(_connectionString, npgsqlOptions =>
|
||||||
|
{
|
||||||
|
npgsqlOptions.CommandTimeout(15);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.UseInMemoryDatabase(_inMemoryDbName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AppDbContext(builder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,972 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cloud;
|
||||||
|
using Cloud.Models;
|
||||||
|
using DatabaseTest.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Model;
|
||||||
|
|
||||||
|
namespace DatabaseTest.Services;
|
||||||
|
|
||||||
|
public interface IDatabaseService
|
||||||
|
{
|
||||||
|
Task<DatabaseHealthStatus> CheckHealthAsync();
|
||||||
|
Task<MigrationReport> GetMigrationStatusAsync();
|
||||||
|
Task<OperationResult> ApplyMigrationsAsync();
|
||||||
|
Task<OperationResult> EnsureDatabaseCreatedAsync();
|
||||||
|
Task<OperationResult> ResetDatabaseAsync();
|
||||||
|
Task<SeedResult> SeedSampleDataAsync();
|
||||||
|
|
||||||
|
// Card Notes
|
||||||
|
Task<List<CardNote>> GetCardNotesAsync(string? search = null);
|
||||||
|
Task<CardNote?> GetCardNoteAsync(string cardName);
|
||||||
|
Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew);
|
||||||
|
Task<OperationResult> DeleteCardNoteAsync(string cardName);
|
||||||
|
|
||||||
|
// User Decks
|
||||||
|
Task<List<UserDeck>> GetUserDecksAsync(string? search = null);
|
||||||
|
Task<UserDeck?> GetUserDeckAsync(Guid id);
|
||||||
|
Task<OperationResult> SaveUserDeckAsync(UserDeck deck, bool isNew);
|
||||||
|
Task<OperationResult> DeleteUserDeckAsync(Guid id);
|
||||||
|
|
||||||
|
// Passkey Credentials
|
||||||
|
Task<List<PasskeyCredential>> GetPasskeysAsync(string? search = null);
|
||||||
|
Task<PasskeyCredential?> GetPasskeyAsync(int id);
|
||||||
|
Task<OperationResult> SavePasskeyAsync(PasskeyCredential passkey, bool isNew);
|
||||||
|
Task<OperationResult> DeletePasskeyAsync(int id);
|
||||||
|
|
||||||
|
// Automated Test Suite
|
||||||
|
Task<List<DatabaseTestCaseResult>> RunAllTestsAsync(Action<string>? progressCallback = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DatabaseService : IDatabaseService
|
||||||
|
{
|
||||||
|
private readonly IDatabaseConfigService _configService;
|
||||||
|
private readonly ILogger<DatabaseService> _logger;
|
||||||
|
|
||||||
|
public DatabaseService(IDatabaseConfigService configService, ILogger<DatabaseService> logger)
|
||||||
|
{
|
||||||
|
_configService = configService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DatabaseHealthStatus> CheckHealthAsync()
|
||||||
|
{
|
||||||
|
var status = new DatabaseHealthStatus
|
||||||
|
{
|
||||||
|
ProviderType = _configService.ProviderType,
|
||||||
|
ProviderName = _configService.ProviderType.ToString(),
|
||||||
|
ConnectionString = _configService.ConnectionString,
|
||||||
|
CheckedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var canConnect = await db.Database.CanConnectAsync();
|
||||||
|
sw.Stop();
|
||||||
|
status.ResponseTimeMs = sw.ElapsedMilliseconds;
|
||||||
|
status.IsConnected = canConnect;
|
||||||
|
|
||||||
|
if (canConnect)
|
||||||
|
{
|
||||||
|
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
var pending = await db.Database.GetPendingMigrationsAsync();
|
||||||
|
var applied = await db.Database.GetAppliedMigrationsAsync();
|
||||||
|
status.PendingMigrationsCount = pending.Count();
|
||||||
|
status.AppliedMigrationsCount = applied.Count();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
status.CardNotesCount = await db.CardNotes.CountAsync();
|
||||||
|
status.UserDecksCount = await db.UserDecks.CountAsync();
|
||||||
|
status.PasskeysCount = await db.PasskeyCredentials.CountAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.ErrorMessage = $"Connected, but table count failed (migrations may be pending): {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
status.ErrorMessage = "Unable to connect to database. Verify connection string and server status.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
status.ResponseTimeMs = sw.ElapsedMilliseconds;
|
||||||
|
status.IsConnected = false;
|
||||||
|
status.ErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MigrationReport> GetMigrationStatusAsync()
|
||||||
|
{
|
||||||
|
var report = new MigrationReport
|
||||||
|
{
|
||||||
|
ProviderType = _configService.ProviderType
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
report.SupportsMigrations = false;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.SupportsMigrations = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
report.AppliedMigrations = (await db.Database.GetAppliedMigrationsAsync()).ToList();
|
||||||
|
report.PendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
report.Error = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> ApplyMigrationsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
|
||||||
|
if (!pending.Any())
|
||||||
|
{
|
||||||
|
return OperationResult.Ok("Database schema is already up-to-date. No pending migrations.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
return OperationResult.Ok($"Successfully applied {pending.Count} migration(s): {string.Join(", ", pending)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
return OperationResult.Ok("In-memory database schema ensured.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to apply migrations.");
|
||||||
|
return OperationResult.Fail("Failed to apply migrations: " + ex.Message, ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> EnsureDatabaseCreatedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var created = await db.Database.EnsureCreatedAsync();
|
||||||
|
return OperationResult.Ok(created ? "Database created successfully." : "Database already exists.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to ensure database creation.");
|
||||||
|
return OperationResult.Fail("Failed to ensure database created: " + ex.Message, ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> ResetDatabaseAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
// Delete rows from all tables
|
||||||
|
db.CardNotes.RemoveRange(db.CardNotes);
|
||||||
|
db.UserDecks.RemoveRange(db.UserDecks);
|
||||||
|
db.PasskeyCredentials.RemoveRange(db.PasskeyCredentials);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok("All database tables cleared successfully.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await db.Database.EnsureDeletedAsync();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
return OperationResult.Ok("In-memory database wiped and recreated successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to reset database.");
|
||||||
|
return OperationResult.Fail("Failed to reset database: " + ex.Message, ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SeedResult> SeedSampleDataAsync()
|
||||||
|
{
|
||||||
|
var result = new SeedResult();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Seed Card Notes
|
||||||
|
var existingNoteNames = (await db.CardNotes.Select(n => n.CardName).ToListAsync()).ToHashSet();
|
||||||
|
var sampleNotes = new List<CardNote>
|
||||||
|
{
|
||||||
|
new() { CardName = "Kaelen, Arcane Weaver", Note = "Key combo enabler for control decks. High priority removal target." },
|
||||||
|
new() { CardName = "Valkyrie Ascendant", Note = "Strong mid-game aerial presence. Synergizes well with Divine Shield buffs." },
|
||||||
|
new() { CardName = "Chronos, Time Shifter", Note = "Turn rewind capability allows recovering from early tempo loss." },
|
||||||
|
new() { CardName = "Cyber Samurai", Note = "Fast aggro card with stealth. Great opener." },
|
||||||
|
new() { CardName = "Temporal Rift", Note = "Essential spell for board reset in late game." }
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var note in sampleNotes.Where(n => !existingNoteNames.Contains(n.CardName)))
|
||||||
|
{
|
||||||
|
db.CardNotes.Add(note);
|
||||||
|
result.CardNotesSeeded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Seed User Decks
|
||||||
|
var existingDeckNames = (await db.UserDecks.Select(d => d.Name).ToListAsync()).ToHashSet();
|
||||||
|
var sampleDecks = new List<UserDeck>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Chrono Temporal Control",
|
||||||
|
Notes = "Built around time-shift mechanics and spell stalling.",
|
||||||
|
Season = "Season 1",
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
Cards = new List<string>
|
||||||
|
{
|
||||||
|
"Chronos, Time Shifter",
|
||||||
|
"Chronos, Time Shifter",
|
||||||
|
"Temporal Rift",
|
||||||
|
"Temporal Rift",
|
||||||
|
"Temporal Rift",
|
||||||
|
"Kaelen, Arcane Weaver",
|
||||||
|
"Kaelen, Arcane Weaver"
|
||||||
|
},
|
||||||
|
Divers = new List<string> { "Chronos, Time Shifter" }
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Valkyrie Aggro Burst",
|
||||||
|
Notes = "Fast tempo rush deck targeting rapid face damage.",
|
||||||
|
Season = "Season 1",
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
Cards = new List<string>
|
||||||
|
{
|
||||||
|
"Valkyrie Ascendant",
|
||||||
|
"Valkyrie Ascendant",
|
||||||
|
"Valkyrie Ascendant",
|
||||||
|
"Cyber Samurai",
|
||||||
|
"Cyber Samurai",
|
||||||
|
"Cyber Samurai"
|
||||||
|
},
|
||||||
|
Divers = new List<string>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var deck in sampleDecks.Where(d => !existingDeckNames.Contains(d.Name)))
|
||||||
|
{
|
||||||
|
db.UserDecks.Add(deck);
|
||||||
|
result.UserDecksSeeded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Seed Passkey Credentials
|
||||||
|
var existingPasskeyCount = await db.PasskeyCredentials.CountAsync();
|
||||||
|
if (existingPasskeyCount == 0)
|
||||||
|
{
|
||||||
|
var samplePasskeys = new List<PasskeyCredential>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
CredentialId = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD, 0x01, 0x02, 0x03, 0x04, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80 },
|
||||||
|
PublicKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07 },
|
||||||
|
UserHandle = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04 },
|
||||||
|
AaGuid = Guid.NewGuid().ToString(),
|
||||||
|
SignCount = 42
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
CredentialId = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 },
|
||||||
|
PublicKey = new byte[] { 0x04, 0x1F, 0x2E, 0x3D, 0x4C, 0x5B, 0x6A, 0x79, 0x88, 0x97, 0xA6, 0xB5, 0xC4, 0xD3, 0xE2, 0xF1 },
|
||||||
|
UserHandle = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE, 0x05, 0x06, 0x07, 0x08 },
|
||||||
|
AaGuid = Guid.NewGuid().ToString(),
|
||||||
|
SignCount = 100
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
db.PasskeyCredentials.AddRange(samplePasskeys);
|
||||||
|
result.PasskeysSeeded += samplePasskeys.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
result.Success = true;
|
||||||
|
result.Message = $"Sample data seeded: {result.CardNotesSeeded} notes, {result.UserDecksSeeded} decks, {result.PasskeysSeeded} passkeys.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to seed sample data.");
|
||||||
|
result.Success = false;
|
||||||
|
result.Message = "Failed to seed data: " + ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card Notes CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<CardNote>> GetCardNotesAsync(string? search = null)
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var query = db.CardNotes.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.Trim().ToLower();
|
||||||
|
query = query.Where(n => n.CardName.ToLower().Contains(s) || n.Note.ToLower().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.OrderBy(n => n.CardName).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CardNote?> GetCardNoteAsync(string cardName)
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
return await db.CardNotes.AsNoTracking().FirstOrDefaultAsync(n => n.CardName == cardName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(note.CardName))
|
||||||
|
return OperationResult.Fail("Card name is required.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
var exists = await db.CardNotes.AnyAsync(n => n.CardName == note.CardName);
|
||||||
|
if (exists)
|
||||||
|
return OperationResult.Fail($"A note for card '{note.CardName}' already exists.");
|
||||||
|
|
||||||
|
db.CardNotes.Add(note);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Created note for '{note.CardName}'.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == note.CardName);
|
||||||
|
if (existing == null)
|
||||||
|
return OperationResult.Fail($"Note for card '{note.CardName}' was not found.");
|
||||||
|
|
||||||
|
existing.Note = note.Note;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Updated note for '{note.CardName}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save card note {CardName}", note.CardName);
|
||||||
|
return OperationResult.Fail($"Error saving card note: {ex.Message}", ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> DeleteCardNoteAsync(string cardName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var existing = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == cardName);
|
||||||
|
if (existing == null)
|
||||||
|
return OperationResult.Fail($"Note for '{cardName}' not found.");
|
||||||
|
|
||||||
|
db.CardNotes.Remove(existing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Deleted note for '{cardName}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete card note {CardName}", cardName);
|
||||||
|
return OperationResult.Fail($"Error deleting note: {ex.Message}", ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Decks CRUD ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<UserDeck>> GetUserDecksAsync(string? search = null)
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var query = db.UserDecks.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.Trim().ToLower();
|
||||||
|
query = query.Where(d => d.Name.ToLower().Contains(s)
|
||||||
|
|| (d.Notes != null && d.Notes.ToLower().Contains(s))
|
||||||
|
|| (d.Season != null && d.Season.ToLower().Contains(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.OrderByDescending(d => d.UpdatedAt).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserDeck?> GetUserDeckAsync(Guid id)
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
return await db.UserDecks.AsNoTracking().FirstOrDefaultAsync(d => d.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> SaveUserDeckAsync(UserDeck deck, bool isNew)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deck.Name))
|
||||||
|
return OperationResult.Fail("Deck name is required.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
deck.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
if (deck.Id == Guid.Empty)
|
||||||
|
deck.Id = Guid.NewGuid();
|
||||||
|
|
||||||
|
db.UserDecks.Add(deck);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Created deck '{deck.Name}'.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == deck.Id);
|
||||||
|
if (existing == null)
|
||||||
|
return OperationResult.Fail($"Deck with ID {deck.Id} was not found.");
|
||||||
|
|
||||||
|
existing.Name = deck.Name;
|
||||||
|
existing.Notes = deck.Notes;
|
||||||
|
existing.Season = deck.Season;
|
||||||
|
existing.Cards = deck.Cards ?? [];
|
||||||
|
existing.Divers = deck.Divers ?? [];
|
||||||
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Updated deck '{deck.Name}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save deck {DeckName}", deck.Name);
|
||||||
|
return OperationResult.Fail($"Error saving deck: {ex.Message}", ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> DeleteUserDeckAsync(Guid id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var existing = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == id);
|
||||||
|
if (existing == null)
|
||||||
|
return OperationResult.Fail($"Deck with ID {id} was not found.");
|
||||||
|
|
||||||
|
var name = existing.Name;
|
||||||
|
db.UserDecks.Remove(existing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Deleted deck '{name}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete deck {DeckId}", id);
|
||||||
|
return OperationResult.Fail($"Error deleting deck: {ex.Message}", ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Passkey Credentials CRUD ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<PasskeyCredential>> GetPasskeysAsync(string? search = null)
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var query = db.PasskeyCredentials.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.Trim().ToLower();
|
||||||
|
query = query.Where(p => (p.AaGuid != null && p.AaGuid.ToLower().Contains(s))
|
||||||
|
|| p.Id.ToString().Contains(s)
|
||||||
|
|| p.SignCount.ToString().Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.OrderBy(p => p.Id).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PasskeyCredential?> GetPasskeyAsync(int id)
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
return await db.PasskeyCredentials.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> SavePasskeyAsync(PasskeyCredential passkey, bool isNew)
|
||||||
|
{
|
||||||
|
if (passkey.CredentialId == null || passkey.CredentialId.Length == 0)
|
||||||
|
return OperationResult.Fail("Credential ID bytes are required.");
|
||||||
|
if (passkey.PublicKey == null || passkey.PublicKey.Length == 0)
|
||||||
|
return OperationResult.Fail("Public Key bytes are required.");
|
||||||
|
if (passkey.UserHandle == null || passkey.UserHandle.Length == 0)
|
||||||
|
return OperationResult.Fail("User Handle bytes are required.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
if (isNew)
|
||||||
|
{
|
||||||
|
db.PasskeyCredentials.Add(passkey);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Created Passkey credential #{passkey.Id}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == passkey.Id);
|
||||||
|
if (existing == null)
|
||||||
|
return OperationResult.Fail($"Passkey with ID {passkey.Id} was not found.");
|
||||||
|
|
||||||
|
existing.CredentialId = passkey.CredentialId;
|
||||||
|
existing.PublicKey = passkey.PublicKey;
|
||||||
|
existing.UserHandle = passkey.UserHandle;
|
||||||
|
existing.AaGuid = passkey.AaGuid;
|
||||||
|
existing.SignCount = passkey.SignCount;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Updated Passkey credential #{passkey.Id}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save passkey credential {Id}", passkey.Id);
|
||||||
|
return OperationResult.Fail($"Error saving passkey: {ex.Message}", ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<OperationResult> DeletePasskeyAsync(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var existing = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
if (existing == null)
|
||||||
|
return OperationResult.Fail($"Passkey with ID {id} was not found.");
|
||||||
|
|
||||||
|
db.PasskeyCredentials.Remove(existing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return OperationResult.Ok($"Deleted Passkey credential #{id}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete passkey credential {Id}", id);
|
||||||
|
return OperationResult.Fail($"Error deleting passkey: {ex.Message}", ex.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Automated Test Suite Runner ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<List<DatabaseTestCaseResult>> RunAllTestsAsync(Action<string>? progressCallback = null)
|
||||||
|
{
|
||||||
|
var results = new List<DatabaseTestCaseResult>();
|
||||||
|
|
||||||
|
// 1. Connection Test
|
||||||
|
results.Add(await RunTestCaseAsync("DB-01", "Database Connectivity", "Connection",
|
||||||
|
"Validates socket/connection pool access to the target database server.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
test.AddLog($"Connecting to {_configService.GetActiveProviderDescription()}...");
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var canConnect = await db.Database.CanConnectAsync();
|
||||||
|
if (!canConnect)
|
||||||
|
throw new InvalidOperationException("Failed to establish a connection to the database.");
|
||||||
|
|
||||||
|
test.AddLog("Database connection established successfully.");
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
// 2. Migrations & Schema Check
|
||||||
|
results.Add(await RunTestCaseAsync("DB-02", "Schema & Migrations", "Schema",
|
||||||
|
"Verifies applied EF Core migrations and checks that all tables exist.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
if (_configService.ProviderType == DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
test.AddLog("Querying EF Core migration history...");
|
||||||
|
var applied = (await db.Database.GetAppliedMigrationsAsync()).ToList();
|
||||||
|
test.AddLog($"Applied migrations count: {applied.Count}");
|
||||||
|
foreach (var m in applied) test.AddLog($" - Applied: {m}");
|
||||||
|
|
||||||
|
var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
|
||||||
|
if (pending.Any())
|
||||||
|
{
|
||||||
|
test.AddLog($"Warning: {pending.Count} pending migrations detected. Applying them now...");
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
test.AddLog("Pending migrations applied successfully.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
test.AddLog("No pending migrations. Schema is up to date.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
test.AddLog("In-memory provider: Ensuring database schema is created...");
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
test.AddLog("In-memory schema verified.");
|
||||||
|
}
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
// 3. CardNote CRUD Test
|
||||||
|
results.Add(await RunTestCaseAsync("DB-03", "CardNote Full CRUD Lifecycle", "CardNotes",
|
||||||
|
"Tests Insert, Query, Update, and Delete operations on the CardNotes entity.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
var testCardName = $"__Test_Card_{Guid.NewGuid():N}";
|
||||||
|
var initialNote = "Initial test note description";
|
||||||
|
var updatedNote = "Updated test note content with extra details";
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog($"1. Creating test CardNote for '{testCardName}'...");
|
||||||
|
db.CardNotes.Add(new CardNote { CardName = testCardName, Note = initialNote });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("CardNote inserted successfully.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("2. Querying inserted CardNote from database...");
|
||||||
|
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
|
||||||
|
if (fetched == null || fetched.Note != initialNote)
|
||||||
|
throw new Exception($"Expected note '{initialNote}', but retrieved '{fetched?.Note}'");
|
||||||
|
test.AddLog("CardNote retrieved and validated.");
|
||||||
|
|
||||||
|
test.AddLog("3. Updating CardNote text...");
|
||||||
|
fetched.Note = updatedNote;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("CardNote updated in database.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("4. Verifying persisted updated note...");
|
||||||
|
var fetched = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == testCardName);
|
||||||
|
if (fetched == null || fetched.Note != updatedNote)
|
||||||
|
throw new Exception($"Expected updated note '{updatedNote}', but found '{fetched?.Note}'");
|
||||||
|
test.AddLog("Update persistence verified.");
|
||||||
|
|
||||||
|
test.AddLog("5. Deleting test CardNote...");
|
||||||
|
db.CardNotes.Remove(fetched);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("CardNote deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("6. Verifying CardNote is deleted...");
|
||||||
|
var exists = await db.CardNotes.AnyAsync(n => n.CardName == testCardName);
|
||||||
|
if (exists)
|
||||||
|
throw new Exception("CardNote still exists after deletion.");
|
||||||
|
test.AddLog("CardNote deletion verified.");
|
||||||
|
}
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
// 4. UserDeck CRUD & JSONB Serialization Test
|
||||||
|
results.Add(await RunTestCaseAsync("DB-04", "UserDeck CRUD & JSONB Mapping", "UserDecks",
|
||||||
|
"Validates JSON column mapping for Cards and Divers collections in PostgreSQL/EF Core.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
var testDeckId = Guid.NewGuid();
|
||||||
|
var testDeckName = $"Test Deck {Guid.NewGuid():N}";
|
||||||
|
|
||||||
|
var initialCards = new List<string>
|
||||||
|
{
|
||||||
|
"Agent Alpha",
|
||||||
|
"Agent Alpha",
|
||||||
|
"Agent Alpha",
|
||||||
|
"Spell Omega",
|
||||||
|
"Spell Omega"
|
||||||
|
};
|
||||||
|
var initialDivers = new List<string> { "Agent Alpha" };
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog($"1. Inserting UserDeck '{testDeckName}' with {initialCards.Count} cards and {initialDivers.Count} divers...");
|
||||||
|
var deck = new UserDeck
|
||||||
|
{
|
||||||
|
Id = testDeckId,
|
||||||
|
Name = testDeckName,
|
||||||
|
Notes = "Automated test deck notes",
|
||||||
|
Season = "Test Season 1",
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
Cards = initialCards,
|
||||||
|
Divers = initialDivers
|
||||||
|
};
|
||||||
|
db.UserDecks.Add(deck);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("UserDeck saved with JSON structures.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("2. Querying UserDeck and checking deserialized JSON lists...");
|
||||||
|
var fetched = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == testDeckId);
|
||||||
|
if (fetched == null) throw new Exception("UserDeck not found after insertion.");
|
||||||
|
if (fetched.Cards == null || fetched.Cards.Count != 5)
|
||||||
|
throw new Exception($"Expected 5 cards in deck, found {fetched.Cards?.Count ?? 0}");
|
||||||
|
if (fetched.Divers == null || fetched.Divers.Count != 1 || fetched.Divers[0] != "Agent Alpha")
|
||||||
|
throw new Exception($"Diver entry deserialization mismatch.");
|
||||||
|
test.AddLog("JSON properties successfully verified.");
|
||||||
|
|
||||||
|
test.AddLog("3. Modifying cards list in UserDeck...");
|
||||||
|
fetched.Cards.Add("Added Card Gamma");
|
||||||
|
fetched.Divers.Add("Added Diver");
|
||||||
|
fetched.Name = testDeckName + " (Updated)";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("Updated deck with additional cards and divers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("4. Validating updated cards and divers...");
|
||||||
|
var fetched = await db.UserDecks.FirstOrDefaultAsync(d => d.Id == testDeckId);
|
||||||
|
if (fetched == null || fetched.Cards?.Count != 6 || fetched.Divers?.Count != 2)
|
||||||
|
throw new Exception("Updated JSON collections did not persist correctly.");
|
||||||
|
test.AddLog("Updated JSON verified.");
|
||||||
|
|
||||||
|
test.AddLog("5. Deleting UserDeck...");
|
||||||
|
db.UserDecks.Remove(fetched);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("UserDeck deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
var exists = await db.UserDecks.AnyAsync(d => d.Id == testDeckId);
|
||||||
|
if (exists) throw new Exception("UserDeck still exists after deletion.");
|
||||||
|
test.AddLog("UserDeck deletion confirmed.");
|
||||||
|
}
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
// 5. PasskeyCredential Binary Handling Test
|
||||||
|
results.Add(await RunTestCaseAsync("DB-05", "PasskeyCredential Binary Storage", "Passkeys",
|
||||||
|
"Validates byte array (byte[]) handling for CredentialId, PublicKey, and UserHandle.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
var credId = new byte[] { 0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xDD, 0x10, 0x20, 0x30, 0x40 };
|
||||||
|
var pubKey = new byte[] { 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86 };
|
||||||
|
var userHandle = new byte[] { 0xFE, 0xDC, 0xBA, 0x98 };
|
||||||
|
var aaGuid = Guid.NewGuid().ToString();
|
||||||
|
long signCount = 100;
|
||||||
|
int savedId = 0;
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("1. Creating PasskeyCredential with raw byte arrays...");
|
||||||
|
var passkey = new PasskeyCredential
|
||||||
|
{
|
||||||
|
CredentialId = credId,
|
||||||
|
PublicKey = pubKey,
|
||||||
|
UserHandle = userHandle,
|
||||||
|
AaGuid = aaGuid,
|
||||||
|
SignCount = signCount
|
||||||
|
};
|
||||||
|
db.PasskeyCredentials.Add(passkey);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
savedId = passkey.Id;
|
||||||
|
test.AddLog($"Passkey stored with generated ID: {savedId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("2. Querying Passkey and verifying byte-for-byte exact equality...");
|
||||||
|
var fetched = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == savedId);
|
||||||
|
if (fetched == null) throw new Exception("Passkey not found after insertion.");
|
||||||
|
|
||||||
|
if (!fetched.CredentialId.SequenceEqual(credId))
|
||||||
|
throw new Exception("CredentialId byte array does not match original bytes.");
|
||||||
|
if (!fetched.PublicKey.SequenceEqual(pubKey))
|
||||||
|
throw new Exception("PublicKey byte array does not match original bytes.");
|
||||||
|
if (!fetched.UserHandle.SequenceEqual(userHandle))
|
||||||
|
throw new Exception("UserHandle byte array does not match original bytes.");
|
||||||
|
if (fetched.SignCount != signCount)
|
||||||
|
throw new Exception($"SignCount mismatch: expected {signCount}, got {fetched.SignCount}");
|
||||||
|
|
||||||
|
test.AddLog("Binary fields verified successfully.");
|
||||||
|
|
||||||
|
test.AddLog("3. Incrementing SignCount...");
|
||||||
|
fetched.SignCount += 1;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog($"Updated SignCount to {fetched.SignCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
var fetched = await db.PasskeyCredentials.FirstOrDefaultAsync(p => p.Id == savedId);
|
||||||
|
if (fetched == null || fetched.SignCount != 101)
|
||||||
|
throw new Exception("SignCount increment was not persisted.");
|
||||||
|
|
||||||
|
test.AddLog("4. Deleting Passkey...");
|
||||||
|
db.PasskeyCredentials.Remove(fetched);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("Passkey deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
var exists = await db.PasskeyCredentials.AnyAsync(p => p.Id == savedId);
|
||||||
|
if (exists) throw new Exception("Passkey still exists after deletion.");
|
||||||
|
test.AddLog("Passkey deletion confirmed.");
|
||||||
|
}
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
// 6. Primary Key & Duplicate Key Constraint Test
|
||||||
|
results.Add(await RunTestCaseAsync("DB-06", "Primary Key & Duplicate Constraints", "Integrity",
|
||||||
|
"Verifies that attempting to insert a duplicate primary key throws DbUpdateException.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
var duplicateKey = $"__DupTest_{Guid.NewGuid():N}";
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog($"1. Inserting initial record with key '{duplicateKey}'...");
|
||||||
|
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "First note" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("Initial record created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("2. Attempting to insert duplicate record with same primary key...");
|
||||||
|
db.CardNotes.Add(new CardNote { CardName = duplicateKey, Note = "Second duplicate note" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Expected exception on duplicate primary key, but operation succeeded!");
|
||||||
|
}
|
||||||
|
catch (DbUpdateException dbEx)
|
||||||
|
{
|
||||||
|
test.AddLog($"Expected DbUpdateException caught: {dbEx.Message}");
|
||||||
|
}
|
||||||
|
catch (ArgumentException argEx)
|
||||||
|
{
|
||||||
|
test.AddLog($"Expected ArgumentException caught: {argEx.Message}");
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException invEx)
|
||||||
|
{
|
||||||
|
test.AddLog($"Expected key constraint exception caught: {invEx.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Cleanup
|
||||||
|
await using var db = _configService.CreateDbContext();
|
||||||
|
var toClean = await db.CardNotes.FirstOrDefaultAsync(n => n.CardName == duplicateKey);
|
||||||
|
if (toClean != null)
|
||||||
|
{
|
||||||
|
db.CardNotes.Remove(toClean);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
test.AddLog("Cleaned up test record.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
// 7. Transaction Rollback Verification
|
||||||
|
results.Add(await RunTestCaseAsync("DB-07", "Transaction Rollback Support", "Transactions",
|
||||||
|
"Verifies that uncommitted transactions roll back changes cleanly on failure.",
|
||||||
|
async test =>
|
||||||
|
{
|
||||||
|
if (_configService.ProviderType != DatabaseProviderType.PostgreSQL)
|
||||||
|
{
|
||||||
|
test.AddLog("Transaction testing requires relational provider (PostgreSQL). Skipping for In-Memory.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transCard = $"__Trans_{Guid.NewGuid():N}";
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("1. Beginning transaction...");
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
test.AddLog($"2. Inserting card '{transCard}' inside transaction...");
|
||||||
|
db.CardNotes.Add(new CardNote { CardName = transCard, Note = "Transaction Note" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
test.AddLog("3. Rolling back transaction explicitly without committing...");
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
test.AddLog("Transaction rolled back.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = _configService.CreateDbContext())
|
||||||
|
{
|
||||||
|
test.AddLog("4. Verifying that the rolled back card note was NOT persisted...");
|
||||||
|
var exists = await db.CardNotes.AnyAsync(n => n.CardName == transCard);
|
||||||
|
if (exists)
|
||||||
|
throw new Exception("Record was persisted despite transaction rollback!");
|
||||||
|
test.AddLog("Rollback verified: record does not exist in database.");
|
||||||
|
}
|
||||||
|
}, progressCallback));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<DatabaseTestCaseResult> RunTestCaseAsync(
|
||||||
|
string id,
|
||||||
|
string name,
|
||||||
|
string category,
|
||||||
|
string description,
|
||||||
|
Func<DatabaseTestCaseResult, Task> testAction,
|
||||||
|
Action<string>? progressCallback)
|
||||||
|
{
|
||||||
|
var test = new DatabaseTestCaseResult
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Category = category,
|
||||||
|
Description = description,
|
||||||
|
Status = TestStatus.Running
|
||||||
|
};
|
||||||
|
|
||||||
|
progressCallback?.Invoke($"Running {name}...");
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await testAction(test);
|
||||||
|
sw.Stop();
|
||||||
|
test.DurationMs = sw.ElapsedMilliseconds;
|
||||||
|
test.Status = TestStatus.Passed;
|
||||||
|
test.AddLog($"Test PASSED in {test.DurationMs}ms.");
|
||||||
|
progressCallback?.Invoke($"Passed {name} ({test.DurationMs}ms)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
test.DurationMs = sw.ElapsedMilliseconds;
|
||||||
|
test.Status = TestStatus.Failed;
|
||||||
|
test.ErrorMessage = ex.Message;
|
||||||
|
test.StackTrace = ex.StackTrace;
|
||||||
|
test.AddLog($"Test FAILED: {ex.Message}");
|
||||||
|
progressCallback?.Invoke($"Failed {name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return test;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
/* Modern Chrono CCG Database Test Dashboard Styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--chrono-bg: #0d1117;
|
||||||
|
--chrono-card-bg: #161b22;
|
||||||
|
--chrono-card-border: #30363d;
|
||||||
|
--chrono-primary: #58a6ff;
|
||||||
|
--chrono-success: #3fb950;
|
||||||
|
--chrono-warning: #d29922;
|
||||||
|
--chrono-danger: #f85149;
|
||||||
|
--chrono-purple: #bc8cff;
|
||||||
|
--chrono-cyan: #39c5cf;
|
||||||
|
--chrono-text-primary: #c9d1d9;
|
||||||
|
--chrono-text-secondary: #8b949e;
|
||||||
|
--chrono-hover-bg: #21262d;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--chrono-bg);
|
||||||
|
color: var(--chrono-text-primary);
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background-color: #010409;
|
||||||
|
border-right: 1px solid var(--chrono-card-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-navbar {
|
||||||
|
background-color: #161b22;
|
||||||
|
border-bottom: 1px solid var(--chrono-card-border);
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Styling */
|
||||||
|
.nav-brand {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--chrono-card-border);
|
||||||
|
color: #f0f6fc;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand i {
|
||||||
|
color: var(--chrono-primary);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu .nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
color: var(--chrono-text-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu .nav-link:hover {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: var(--chrono-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu .nav-link.active {
|
||||||
|
color: #f0f6fc;
|
||||||
|
background-color: #1f6feb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu .nav-link i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--chrono-card-border);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--chrono-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
background-color: rgba(63, 185, 80, 0.15);
|
||||||
|
color: #3fb950;
|
||||||
|
border: 1px solid rgba(63, 185, 80, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
background-color: rgba(248, 81, 73, 0.15);
|
||||||
|
color: #f85149;
|
||||||
|
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inmemory {
|
||||||
|
background-color: rgba(188, 140, 255, 0.15);
|
||||||
|
color: #bc8cff;
|
||||||
|
border: 1px solid rgba(188, 140, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.green {
|
||||||
|
background-color: #3fb950;
|
||||||
|
box-shadow: 0 0 8px #3fb950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.red {
|
||||||
|
background-color: #f85149;
|
||||||
|
box-shadow: 0 0 8px #f85149;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.purple {
|
||||||
|
background-color: #bc8cff;
|
||||||
|
box-shadow: 0 0 8px #bc8cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chrono Cards & Containers */
|
||||||
|
.chrono-card {
|
||||||
|
background-color: var(--chrono-card-bg);
|
||||||
|
border: 1px solid var(--chrono-card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chrono-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--chrono-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chrono-card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f6fc;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metric Cards */
|
||||||
|
.metric-card {
|
||||||
|
background-color: var(--chrono-card-bg);
|
||||||
|
border: 1px solid var(--chrono-card-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--chrono-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon-box {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon-blue {
|
||||||
|
background-color: rgba(88, 166, 255, 0.15);
|
||||||
|
color: var(--chrono-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon-green {
|
||||||
|
background-color: rgba(63, 185, 80, 0.15);
|
||||||
|
color: var(--chrono-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon-purple {
|
||||||
|
background-color: rgba(188, 140, 255, 0.15);
|
||||||
|
color: var(--chrono-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-icon-yellow {
|
||||||
|
background-color: rgba(210, 153, 34, 0.15);
|
||||||
|
color: var(--chrono-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f0f6fc;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--chrono-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Table */
|
||||||
|
.chrono-table {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--chrono-text-primary);
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chrono-table th {
|
||||||
|
background-color: #0d1117;
|
||||||
|
color: var(--chrono-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--chrono-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chrono-table td {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid #21262d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chrono-table tbody tr:hover {
|
||||||
|
background-color: var(--chrono-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Modals */
|
||||||
|
.modal-backdrop-custom {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1050;
|
||||||
|
padding: 1rem;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog-custom {
|
||||||
|
background-color: #161b22;
|
||||||
|
border: 1px solid var(--chrono-card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 650px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-custom {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--chrono-card-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-custom {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f0f6fc;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body-custom {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer-custom {
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--chrono-card-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Controls */
|
||||||
|
.form-control-chrono, .form-select-chrono {
|
||||||
|
background-color: #0d1117;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
color: #f0f6fc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control-chrono:focus, .form-select-chrono:focus {
|
||||||
|
background-color: #0d1117;
|
||||||
|
border-color: var(--chrono-primary);
|
||||||
|
color: #f0f6fc;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log Console */
|
||||||
|
.log-console {
|
||||||
|
background-color: #090d13;
|
||||||
|
border: 1px solid var(--chrono-card-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #7ee787;
|
||||||
|
max-height: 350px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background-color: #090d13;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: #a5d6ff;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-chrono-primary {
|
||||||
|
background-color: #238636;
|
||||||
|
border: 1px solid rgba(240, 246, 252, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-primary:hover {
|
||||||
|
background-color: #2ea043;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-secondary {
|
||||||
|
background-color: #21262d;
|
||||||
|
border: 1px solid #363b42;
|
||||||
|
color: #c9d1d9;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-secondary:hover {
|
||||||
|
background-color: #30363d;
|
||||||
|
color: #f0f6fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-danger {
|
||||||
|
background-color: #b62324;
|
||||||
|
border: 1px solid rgba(240, 246, 252, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-danger:hover {
|
||||||
|
background-color: #da3633;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-outline-primary {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--chrono-primary);
|
||||||
|
color: var(--chrono-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-chrono-outline-primary:hover {
|
||||||
|
background-color: rgba(88, 166, 255, 0.15);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2024 The Bootstrap Authors
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme=light] {
|
||||||
|
--bs-blue: #0d6efd;
|
||||||
|
--bs-indigo: #6610f2;
|
||||||
|
--bs-purple: #6f42c1;
|
||||||
|
--bs-pink: #d63384;
|
||||||
|
--bs-red: #dc3545;
|
||||||
|
--bs-orange: #fd7e14;
|
||||||
|
--bs-yellow: #ffc107;
|
||||||
|
--bs-green: #198754;
|
||||||
|
--bs-teal: #20c997;
|
||||||
|
--bs-cyan: #0dcaf0;
|
||||||
|
--bs-black: #000;
|
||||||
|
--bs-white: #fff;
|
||||||
|
--bs-gray: #6c757d;
|
||||||
|
--bs-gray-dark: #343a40;
|
||||||
|
--bs-gray-100: #f8f9fa;
|
||||||
|
--bs-gray-200: #e9ecef;
|
||||||
|
--bs-gray-300: #dee2e6;
|
||||||
|
--bs-gray-400: #ced4da;
|
||||||
|
--bs-gray-500: #adb5bd;
|
||||||
|
--bs-gray-600: #6c757d;
|
||||||
|
--bs-gray-700: #495057;
|
||||||
|
--bs-gray-800: #343a40;
|
||||||
|
--bs-gray-900: #212529;
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-success: #198754;
|
||||||
|
--bs-info: #0dcaf0;
|
||||||
|
--bs-warning: #ffc107;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-light: #f8f9fa;
|
||||||
|
--bs-dark: #212529;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary-rgb: 108, 117, 125;
|
||||||
|
--bs-success-rgb: 25, 135, 84;
|
||||||
|
--bs-info-rgb: 13, 202, 240;
|
||||||
|
--bs-warning-rgb: 255, 193, 7;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-light-rgb: 248, 249, 250;
|
||||||
|
--bs-dark-rgb: 33, 37, 41;
|
||||||
|
--bs-primary-text-emphasis: #052c65;
|
||||||
|
--bs-secondary-text-emphasis: #2b2f32;
|
||||||
|
--bs-success-text-emphasis: #0a3622;
|
||||||
|
--bs-info-text-emphasis: #055160;
|
||||||
|
--bs-warning-text-emphasis: #664d03;
|
||||||
|
--bs-danger-text-emphasis: #58151c;
|
||||||
|
--bs-light-text-emphasis: #495057;
|
||||||
|
--bs-dark-text-emphasis: #495057;
|
||||||
|
--bs-primary-bg-subtle: #cfe2ff;
|
||||||
|
--bs-secondary-bg-subtle: #e2e3e5;
|
||||||
|
--bs-success-bg-subtle: #d1e7dd;
|
||||||
|
--bs-info-bg-subtle: #cff4fc;
|
||||||
|
--bs-warning-bg-subtle: #fff3cd;
|
||||||
|
--bs-danger-bg-subtle: #f8d7da;
|
||||||
|
--bs-light-bg-subtle: #fcfcfd;
|
||||||
|
--bs-dark-bg-subtle: #ced4da;
|
||||||
|
--bs-primary-border-subtle: #9ec5fe;
|
||||||
|
--bs-secondary-border-subtle: #c4c8cb;
|
||||||
|
--bs-success-border-subtle: #a3cfbb;
|
||||||
|
--bs-info-border-subtle: #9eeaf9;
|
||||||
|
--bs-warning-border-subtle: #ffe69c;
|
||||||
|
--bs-danger-border-subtle: #f1aeb5;
|
||||||
|
--bs-light-border-subtle: #e9ecef;
|
||||||
|
--bs-dark-border-subtle: #adb5bd;
|
||||||
|
--bs-white-rgb: 255, 255, 255;
|
||||||
|
--bs-black-rgb: 0, 0, 0;
|
||||||
|
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||||
|
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-font-weight: 400;
|
||||||
|
--bs-body-line-height: 1.5;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-body-color-rgb: 33, 37, 41;
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
--bs-body-bg-rgb: 255, 255, 255;
|
||||||
|
--bs-emphasis-color: #000;
|
||||||
|
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||||
|
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-secondary-bg: #e9ecef;
|
||||||
|
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||||
|
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-tertiary-bg: #f8f9fa;
|
||||||
|
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #0d6efd;
|
||||||
|
--bs-link-color-rgb: 13, 110, 253;
|
||||||
|
--bs-link-decoration: underline;
|
||||||
|
--bs-link-hover-color: #0a58ca;
|
||||||
|
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||||
|
--bs-code-color: #d63384;
|
||||||
|
--bs-highlight-color: #212529;
|
||||||
|
--bs-highlight-bg: #fff3cd;
|
||||||
|
--bs-border-width: 1px;
|
||||||
|
--bs-border-style: solid;
|
||||||
|
--bs-border-color: #dee2e6;
|
||||||
|
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-border-radius: 0.375rem;
|
||||||
|
--bs-border-radius-sm: 0.25rem;
|
||||||
|
--bs-border-radius-lg: 0.5rem;
|
||||||
|
--bs-border-radius-xl: 1rem;
|
||||||
|
--bs-border-radius-xxl: 2rem;
|
||||||
|
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||||
|
--bs-border-radius-pill: 50rem;
|
||||||
|
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-focus-ring-width: 0.25rem;
|
||||||
|
--bs-focus-ring-opacity: 0.25;
|
||||||
|
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-form-valid-color: #198754;
|
||||||
|
--bs-form-valid-border-color: #198754;
|
||||||
|
--bs-form-invalid-color: #dc3545;
|
||||||
|
--bs-form-invalid-border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bs-body-color: #dee2e6;
|
||||||
|
--bs-body-color-rgb: 222, 226, 230;
|
||||||
|
--bs-body-bg: #212529;
|
||||||
|
--bs-body-bg-rgb: 33, 37, 41;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||||
|
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-secondary-bg: #343a40;
|
||||||
|
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||||
|
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-tertiary-bg: #2b3035;
|
||||||
|
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||||
|
--bs-primary-text-emphasis: #6ea8fe;
|
||||||
|
--bs-secondary-text-emphasis: #a7acb1;
|
||||||
|
--bs-success-text-emphasis: #75b798;
|
||||||
|
--bs-info-text-emphasis: #6edff6;
|
||||||
|
--bs-warning-text-emphasis: #ffda6a;
|
||||||
|
--bs-danger-text-emphasis: #ea868f;
|
||||||
|
--bs-light-text-emphasis: #f8f9fa;
|
||||||
|
--bs-dark-text-emphasis: #dee2e6;
|
||||||
|
--bs-primary-bg-subtle: #031633;
|
||||||
|
--bs-secondary-bg-subtle: #161719;
|
||||||
|
--bs-success-bg-subtle: #051b11;
|
||||||
|
--bs-info-bg-subtle: #032830;
|
||||||
|
--bs-warning-bg-subtle: #332701;
|
||||||
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
|
--bs-light-bg-subtle: #343a40;
|
||||||
|
--bs-dark-bg-subtle: #1a1d20;
|
||||||
|
--bs-primary-border-subtle: #084298;
|
||||||
|
--bs-secondary-border-subtle: #41464b;
|
||||||
|
--bs-success-border-subtle: #0f5132;
|
||||||
|
--bs-info-border-subtle: #087990;
|
||||||
|
--bs-warning-border-subtle: #997404;
|
||||||
|
--bs-danger-border-subtle: #842029;
|
||||||
|
--bs-light-border-subtle: #495057;
|
||||||
|
--bs-dark-border-subtle: #343a40;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #6ea8fe;
|
||||||
|
--bs-link-hover-color: #8bb9fe;
|
||||||
|
--bs-link-color-rgb: 110, 168, 254;
|
||||||
|
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||||
|
--bs-code-color: #e685b5;
|
||||||
|
--bs-highlight-color: #dee2e6;
|
||||||
|
--bs-highlight-bg: #664d03;
|
||||||
|
--bs-border-color: #495057;
|
||||||
|
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||||
|
--bs-form-valid-color: #75b798;
|
||||||
|
--bs-form-valid-border-color: #75b798;
|
||||||
|
--bs-form-invalid-color: #ea868f;
|
||||||
|
--bs-form-invalid-border-color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--bs-body-font-family);
|
||||||
|
font-size: var(--bs-body-font-size);
|
||||||
|
font-weight: var(--bs-body-font-weight);
|
||||||
|
line-height: var(--bs-body-line-height);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: var(--bs-body-text-align);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--bs-border-width) solid;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6, h5, h4, h3, h2, h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bs-heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(1.375rem + 1.5vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(1.325rem + 0.9vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(1.3rem + 0.6vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
-webkit-text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1875em;
|
||||||
|
color: var(--bs-highlight-color);
|
||||||
|
background-color: var(--bs-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-code-color);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
a > code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
kbd kbd {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
caption-side: bottom;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-color: inherit;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role=button] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type=button],
|
||||||
|
[type=reset],
|
||||||
|
[type=submit] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
[type=button]:not(:disabled),
|
||||||
|
[type=reset]:not(:disabled),
|
||||||
|
[type=submit]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
legend {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legend + * {
|
||||||
|
clear: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
::-webkit-datetime-edit-text,
|
||||||
|
::-webkit-datetime-edit-minute,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* rtl:raw:
|
||||||
|
[type="tel"],
|
||||||
|
[type="url"],
|
||||||
|
[type="email"],
|
||||||
|
[type="number"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
/*!
|
||||||
|
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2024 The Bootstrap Authors
|
||||||
|
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||||
|
*/
|
||||||
|
:root,
|
||||||
|
[data-bs-theme=light] {
|
||||||
|
--bs-blue: #0d6efd;
|
||||||
|
--bs-indigo: #6610f2;
|
||||||
|
--bs-purple: #6f42c1;
|
||||||
|
--bs-pink: #d63384;
|
||||||
|
--bs-red: #dc3545;
|
||||||
|
--bs-orange: #fd7e14;
|
||||||
|
--bs-yellow: #ffc107;
|
||||||
|
--bs-green: #198754;
|
||||||
|
--bs-teal: #20c997;
|
||||||
|
--bs-cyan: #0dcaf0;
|
||||||
|
--bs-black: #000;
|
||||||
|
--bs-white: #fff;
|
||||||
|
--bs-gray: #6c757d;
|
||||||
|
--bs-gray-dark: #343a40;
|
||||||
|
--bs-gray-100: #f8f9fa;
|
||||||
|
--bs-gray-200: #e9ecef;
|
||||||
|
--bs-gray-300: #dee2e6;
|
||||||
|
--bs-gray-400: #ced4da;
|
||||||
|
--bs-gray-500: #adb5bd;
|
||||||
|
--bs-gray-600: #6c757d;
|
||||||
|
--bs-gray-700: #495057;
|
||||||
|
--bs-gray-800: #343a40;
|
||||||
|
--bs-gray-900: #212529;
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-success: #198754;
|
||||||
|
--bs-info: #0dcaf0;
|
||||||
|
--bs-warning: #ffc107;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-light: #f8f9fa;
|
||||||
|
--bs-dark: #212529;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary-rgb: 108, 117, 125;
|
||||||
|
--bs-success-rgb: 25, 135, 84;
|
||||||
|
--bs-info-rgb: 13, 202, 240;
|
||||||
|
--bs-warning-rgb: 255, 193, 7;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-light-rgb: 248, 249, 250;
|
||||||
|
--bs-dark-rgb: 33, 37, 41;
|
||||||
|
--bs-primary-text-emphasis: #052c65;
|
||||||
|
--bs-secondary-text-emphasis: #2b2f32;
|
||||||
|
--bs-success-text-emphasis: #0a3622;
|
||||||
|
--bs-info-text-emphasis: #055160;
|
||||||
|
--bs-warning-text-emphasis: #664d03;
|
||||||
|
--bs-danger-text-emphasis: #58151c;
|
||||||
|
--bs-light-text-emphasis: #495057;
|
||||||
|
--bs-dark-text-emphasis: #495057;
|
||||||
|
--bs-primary-bg-subtle: #cfe2ff;
|
||||||
|
--bs-secondary-bg-subtle: #e2e3e5;
|
||||||
|
--bs-success-bg-subtle: #d1e7dd;
|
||||||
|
--bs-info-bg-subtle: #cff4fc;
|
||||||
|
--bs-warning-bg-subtle: #fff3cd;
|
||||||
|
--bs-danger-bg-subtle: #f8d7da;
|
||||||
|
--bs-light-bg-subtle: #fcfcfd;
|
||||||
|
--bs-dark-bg-subtle: #ced4da;
|
||||||
|
--bs-primary-border-subtle: #9ec5fe;
|
||||||
|
--bs-secondary-border-subtle: #c4c8cb;
|
||||||
|
--bs-success-border-subtle: #a3cfbb;
|
||||||
|
--bs-info-border-subtle: #9eeaf9;
|
||||||
|
--bs-warning-border-subtle: #ffe69c;
|
||||||
|
--bs-danger-border-subtle: #f1aeb5;
|
||||||
|
--bs-light-border-subtle: #e9ecef;
|
||||||
|
--bs-dark-border-subtle: #adb5bd;
|
||||||
|
--bs-white-rgb: 255, 255, 255;
|
||||||
|
--bs-black-rgb: 0, 0, 0;
|
||||||
|
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||||
|
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||||
|
--bs-body-font-size: 1rem;
|
||||||
|
--bs-body-font-weight: 400;
|
||||||
|
--bs-body-line-height: 1.5;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-body-color-rgb: 33, 37, 41;
|
||||||
|
--bs-body-bg: #fff;
|
||||||
|
--bs-body-bg-rgb: 255, 255, 255;
|
||||||
|
--bs-emphasis-color: #000;
|
||||||
|
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||||
|
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-secondary-bg: #e9ecef;
|
||||||
|
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||||
|
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||||
|
--bs-tertiary-bg: #f8f9fa;
|
||||||
|
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #0d6efd;
|
||||||
|
--bs-link-color-rgb: 13, 110, 253;
|
||||||
|
--bs-link-decoration: underline;
|
||||||
|
--bs-link-hover-color: #0a58ca;
|
||||||
|
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||||
|
--bs-code-color: #d63384;
|
||||||
|
--bs-highlight-color: #212529;
|
||||||
|
--bs-highlight-bg: #fff3cd;
|
||||||
|
--bs-border-width: 1px;
|
||||||
|
--bs-border-style: solid;
|
||||||
|
--bs-border-color: #dee2e6;
|
||||||
|
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-border-radius: 0.375rem;
|
||||||
|
--bs-border-radius-sm: 0.25rem;
|
||||||
|
--bs-border-radius-lg: 0.5rem;
|
||||||
|
--bs-border-radius-xl: 1rem;
|
||||||
|
--bs-border-radius-xxl: 2rem;
|
||||||
|
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||||
|
--bs-border-radius-pill: 50rem;
|
||||||
|
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-focus-ring-width: 0.25rem;
|
||||||
|
--bs-focus-ring-opacity: 0.25;
|
||||||
|
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||||
|
--bs-form-valid-color: #198754;
|
||||||
|
--bs-form-valid-border-color: #198754;
|
||||||
|
--bs-form-invalid-color: #dc3545;
|
||||||
|
--bs-form-invalid-border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme=dark] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bs-body-color: #dee2e6;
|
||||||
|
--bs-body-color-rgb: 222, 226, 230;
|
||||||
|
--bs-body-bg: #212529;
|
||||||
|
--bs-body-bg-rgb: 33, 37, 41;
|
||||||
|
--bs-emphasis-color: #fff;
|
||||||
|
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||||
|
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||||
|
--bs-secondary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-secondary-bg: #343a40;
|
||||||
|
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||||
|
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||||
|
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||||
|
--bs-tertiary-bg: #2b3035;
|
||||||
|
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||||
|
--bs-primary-text-emphasis: #6ea8fe;
|
||||||
|
--bs-secondary-text-emphasis: #a7acb1;
|
||||||
|
--bs-success-text-emphasis: #75b798;
|
||||||
|
--bs-info-text-emphasis: #6edff6;
|
||||||
|
--bs-warning-text-emphasis: #ffda6a;
|
||||||
|
--bs-danger-text-emphasis: #ea868f;
|
||||||
|
--bs-light-text-emphasis: #f8f9fa;
|
||||||
|
--bs-dark-text-emphasis: #dee2e6;
|
||||||
|
--bs-primary-bg-subtle: #031633;
|
||||||
|
--bs-secondary-bg-subtle: #161719;
|
||||||
|
--bs-success-bg-subtle: #051b11;
|
||||||
|
--bs-info-bg-subtle: #032830;
|
||||||
|
--bs-warning-bg-subtle: #332701;
|
||||||
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
|
--bs-light-bg-subtle: #343a40;
|
||||||
|
--bs-dark-bg-subtle: #1a1d20;
|
||||||
|
--bs-primary-border-subtle: #084298;
|
||||||
|
--bs-secondary-border-subtle: #41464b;
|
||||||
|
--bs-success-border-subtle: #0f5132;
|
||||||
|
--bs-info-border-subtle: #087990;
|
||||||
|
--bs-warning-border-subtle: #997404;
|
||||||
|
--bs-danger-border-subtle: #842029;
|
||||||
|
--bs-light-border-subtle: #495057;
|
||||||
|
--bs-dark-border-subtle: #343a40;
|
||||||
|
--bs-heading-color: inherit;
|
||||||
|
--bs-link-color: #6ea8fe;
|
||||||
|
--bs-link-hover-color: #8bb9fe;
|
||||||
|
--bs-link-color-rgb: 110, 168, 254;
|
||||||
|
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||||
|
--bs-code-color: #e685b5;
|
||||||
|
--bs-highlight-color: #dee2e6;
|
||||||
|
--bs-highlight-bg: #664d03;
|
||||||
|
--bs-border-color: #495057;
|
||||||
|
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||||
|
--bs-form-valid-color: #75b798;
|
||||||
|
--bs-form-valid-border-color: #75b798;
|
||||||
|
--bs-form-invalid-color: #ea868f;
|
||||||
|
--bs-form-invalid-border-color: #ea868f;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--bs-body-font-family);
|
||||||
|
font-size: var(--bs-body-font-size);
|
||||||
|
font-weight: var(--bs-body-font-weight);
|
||||||
|
line-height: var(--bs-body-line-height);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
text-align: var(--bs-body-text-align);
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: inherit;
|
||||||
|
border: 0;
|
||||||
|
border-top: var(--bs-border-width) solid;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6, h5, h4, h3, h2, h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--bs-heading-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: calc(1.375rem + 1.5vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: calc(1.325rem + 0.9vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: calc(1.3rem + 0.6vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h3 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
h4 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
cursor: help;
|
||||||
|
-webkit-text-decoration-skip-ink: none;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
address {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
dl {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol ol,
|
||||||
|
ul ul,
|
||||||
|
ol ul,
|
||||||
|
ul ol {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
padding: 0.1875em;
|
||||||
|
color: var(--bs-highlight-color);
|
||||||
|
background-color: var(--bs-highlight-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.75em;
|
||||||
|
line-height: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre,
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-code-color);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
a > code {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
padding: 0.1875rem 0.375rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
background-color: var(--bs-body-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
kbd kbd {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
caption-side: bottom;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
caption {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: inherit;
|
||||||
|
text-align: -webkit-match-parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead,
|
||||||
|
tbody,
|
||||||
|
tfoot,
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
border-color: inherit;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus:not(:focus-visible) {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
select,
|
||||||
|
optgroup,
|
||||||
|
textarea {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[role=button] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
select:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type=button],
|
||||||
|
[type=reset],
|
||||||
|
[type=submit] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
button:not(:disabled),
|
||||||
|
[type=button]:not(:disabled),
|
||||||
|
[type=reset]:not(:disabled),
|
||||||
|
[type=submit]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-focus-inner {
|
||||||
|
padding: 0;
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: right;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: calc(1.275rem + 0.3vw);
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
legend {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legend + * {
|
||||||
|
clear: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-datetime-edit-fields-wrapper,
|
||||||
|
::-webkit-datetime-edit-text,
|
||||||
|
::-webkit-datetime-edit-minute,
|
||||||
|
::-webkit-datetime-edit-hour-field,
|
||||||
|
::-webkit-datetime-edit-day-field,
|
||||||
|
::-webkit-datetime-edit-month-field,
|
||||||
|
::-webkit-datetime-edit-year-field {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=search] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="tel"],
|
||||||
|
[type="url"],
|
||||||
|
[type="email"],
|
||||||
|
[type="number"] {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
}
|
||||||
|
|
||||||
|
output {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||||
File diff suppressed because it is too large
Load Diff
+5393
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user