This commit is contained in:
6d486f49
2026-08-17 01:13:48 -04:00
parent 1d6207fbfe
commit ffaab59585
26 changed files with 3133 additions and 105 deletions
-1
View File
@@ -1,4 +1,3 @@
using API.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Model; using Model;
-12
View File
@@ -1,12 +0,0 @@
namespace API.Models;
public class UserDeck
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public List<string> Cards { get; set; } = [];
public List<string> Divers { get; set; } = [];
public string? Notes { get; set; }
public string? Season { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+63
View File
@@ -114,4 +114,67 @@ app.MapGraphQL();
app.MapGet("/", () => "Chrono CCG GraphQL API"); app.MapGet("/", () => "Chrono CCG GraphQL API");
// REST Endpoints for Decks
app.MapGet("/api/decks", async (AppDbContext db) =>
{
var decks = await db.UserDecks.OrderByDescending(d => d.UpdatedAt).ToListAsync();
return Results.Ok(decks);
});
app.MapGet("/api/decks/{id:guid}", async (AppDbContext db, Guid id) =>
{
var deck = await db.UserDecks.FindAsync(id);
return deck != null ? Results.Ok(deck) : Results.NotFound();
});
app.MapPost("/api/decks", async (AppDbContext db, UserDeck input) =>
{
var existing = await db.UserDecks.FindAsync(input.Id);
if (existing == null)
{
input.UpdatedAt = DateTime.UtcNow;
db.UserDecks.Add(input);
await db.SaveChangesAsync();
return Results.Ok(input);
}
existing.Name = input.Name;
existing.Cards = input.Cards ?? [];
existing.Divers = input.Divers ?? [];
existing.Notes = input.Notes;
existing.Season = input.Season;
existing.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(existing);
});
app.MapDelete("/api/decks/{id:guid}", async (AppDbContext db, Guid id) =>
{
var existing = await db.UserDecks.FindAsync(id);
if (existing != null)
{
db.UserDecks.Remove(existing);
await db.SaveChangesAsync();
return Results.Ok(new { success = true });
}
return Results.NotFound();
});
// Authentication endpoints
app.MapPost("/api/auth/dev-login", async (AppDbContext db, ITokenService tokenService, HttpContext httpContext) =>
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == "testuser");
if (user == null)
{
user = new User { Username = "testuser", Password = "password123", CreatedAt = DateTime.UtcNow };
db.Users.Add(user);
await db.SaveChangesAsync();
}
var token = tokenService.GenerateToken(user.Username);
httpContext.Response.Cookies.Append("chrono_auth_token", token, new CookieOptions { Path = "/", SameSite = SameSiteMode.Lax });
httpContext.Response.Cookies.Append("chrono_auth_user", user.Username, new CookieOptions { Path = "/", SameSite = SameSiteMode.Lax });
return Results.Ok(new { success = true, token, username = user.Username });
});
app.MapDelete("/api/auth/credentials", () => Results.Ok(new { success = true }));
app.Run(); app.Run();
+2 -2
View File
@@ -12,11 +12,11 @@
<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@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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"/>
<link rel="stylesheet" href="app.css"/> <link rel="stylesheet" href="app.css"/>
<HeadOutlet/> <HeadOutlet @rendermode="InteractiveServer"/>
</head> </head>
<body> <body>
<Routes/> <Routes @rendermode="InteractiveServer"/>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>
@@ -117,69 +117,63 @@
@if (showEditModal) @if (showEditModal)
{ {
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop-custom">
<div class="modal fade show d-block" tabindex="-1"> <div class="modal-dialog-custom">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-header-custom">
<div class="modal-content chrono-modal"> <h5 class="modal-title-custom">
<div class="modal-header border-secondary border-opacity-25"> <i class="bi @(isNewUser ? "bi-person-plus-fill" : "bi-pencil-square") me-2 text-primary"></i>
<h5 class="modal-title text-white"> @(isNewUser ? "Add New User" : $"Edit User: {currentUser.Username}")
<i class="bi @(isNewUser ? "bi-person-plus-fill" : "bi-pencil-square") me-2 text-primary"></i> </h5>
@(isNewUser ? "Add New User" : $"Edit User: {currentUser.Username}") <button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</h5> </div>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button> <div class="modal-body-custom">
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">USERNAME</label>
<input type="text" class="form-control form-control-chrono"
@bind="currentUser.Username" disabled="@(!isNewUser)"
placeholder="e.g. player1"/>
</div> </div>
<div class="modal-body"> <div class="mb-3">
<div class="mb-3"> <label class="form-label text-secondary small fw-semibold">PASSWORD</label>
<label class="form-label text-secondary small fw-semibold">USERNAME</label> <input type="password" class="form-control form-control-chrono"
<input type="text" class="form-control form-control-chrono" @bind="currentUser.Password"
@bind="currentUser.Username" disabled="@(!isNewUser)" placeholder="Enter password..."/>
placeholder="e.g. player1"/>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">PASSWORD</label>
<input type="password" class="form-control form-control-chrono"
@bind="currentUser.Password"
placeholder="Enter password..."/>
</div>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveUserAsync" disabled="@isSaving">
<i class="bi bi-save me-1"></i> @(isSaving ? "Saving..." : "Save User")
</button>
</div> </div>
</div> </div>
<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="SaveUserAsync" disabled="@isSaving">
<i class="bi bi-save me-1"></i> @(isSaving ? "Saving..." : "Save User")
</button>
</div>
</div> </div>
</div> </div>
} }
@if (showDeleteModal && userToDelete != null) @if (showDeleteModal && userToDelete != null)
{ {
<div class="modal-backdrop fade show"></div> <div class="modal-backdrop-custom">
<div class="modal fade show d-block" tabindex="-1"> <div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-header-custom">
<div class="modal-content chrono-modal"> <h5 class="modal-title-custom text-danger">
<div class="modal-header border-secondary border-opacity-25"> <i class="bi bi-exclamation-triangle-fill me-2"></i> Confirm Delete
<h5 class="modal-title text-danger"> </h5>
<i class="bi bi-exclamation-triangle-fill me-2"></i> Confirm Delete <button type="button" class="btn-close btn-close-white"
</h5> @onclick="() => showDeleteModal = false"></button>
<button type="button" class="btn-close btn-close-white" </div>
@onclick="() => showDeleteModal = false"></button> <div class="modal-body-custom">
</div> <p class="text-light mb-0">
<div class="modal-body"> Are you sure you want to delete user <strong class="text-white">@userToDelete.Username</strong>?
<p class="text-light mb-0"> </p>
Are you sure you want to delete user <strong class="text-white">@userToDelete.Username</strong>? </div>
</p> <div class="modal-footer-custom">
</div> <button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">
<div class="modal-footer border-secondary border-opacity-25"> Cancel
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false"> </button>
Cancel <button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync"
</button> disabled="@isSaving">
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" <i class="bi bi-trash3-fill me-1"></i> @(isSaving ? "Deleting..." : "Delete")
disabled="@isSaving"> </button>
<i class="bi bi-trash3-fill me-1"></i> @(isSaving ? "Deleting..." : "Delete")
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,133 @@
// <auto-generated />
using System;
using Cloud;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Cloud.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260817053000_AddUsersAndSyncCardNotes")]
partial class AddUsersAndSyncCardNotes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DatabaseTest.Models.PasskeyCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
b.Property<string>("AaGuid")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("bytea");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("bytea");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("bytea");
b.HasKey("Id");
b.ToTable("PasskeyCredentials");
});
modelBuilder.Entity("Model.CardNote", b =>
{
b.Property<string>("Username")
.HasColumnType("text");
b.Property<string>("CardName")
.HasColumnType("text");
b.Property<string>("Note")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Username", "CardName");
b.ToTable("CardNotes");
});
modelBuilder.Entity("Model.User", b =>
{
b.Property<string>("Username")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text");
b.HasKey("Username");
b.ToTable("Users");
});
modelBuilder.Entity("Model.UserDeck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<string>("Cards")
.IsRequired()
.HasColumnType("jsonb");
b.PrimitiveCollection<string>("Divers")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Season")
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("UserDecks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cloud.Migrations
{
/// <inheritdoc />
public partial class AddUsersAndSyncCardNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_CardNotes",
table: "CardNotes");
migrationBuilder.AddColumn<string>(
name: "Username",
table: "CardNotes",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "CardNotes",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddPrimaryKey(
name: "PK_CardNotes",
table: "CardNotes",
columns: new[] { "Username", "CardName" });
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Username = table.Column<string>(type: "text", nullable: false),
Password = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Username);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropPrimaryKey(
name: "PK_CardNotes",
table: "CardNotes");
migrationBuilder.DropColumn(
name: "Username",
table: "CardNotes");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "CardNotes");
migrationBuilder.AddPrimaryKey(
name: "PK_CardNotes",
table: "CardNotes",
column: "CardName");
}
}
}
@@ -21,8 +21,11 @@ namespace Cloud.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Chrono.Model.CardNote", b => modelBuilder.Entity("Model.CardNote", b =>
{ {
b.Property<string>("Username")
.HasColumnType("text");
b.Property<string>("CardName") b.Property<string>("CardName")
.HasColumnType("text"); .HasColumnType("text");
@@ -30,12 +33,32 @@ namespace Cloud.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("CardName"); b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Username", "CardName");
b.ToTable("CardNotes"); b.ToTable("CardNotes");
}); });
modelBuilder.Entity("Cloud.Models.PasskeyCredential", b => modelBuilder.Entity("Model.User", b =>
{
b.Property<string>("Username")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("text");
b.HasKey("Username");
b.ToTable("Users");
});
modelBuilder.Entity("DatabaseTest.Models.PasskeyCredential", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -69,7 +92,7 @@ namespace Cloud.Migrations
b.ToTable("PasskeyCredentials"); b.ToTable("PasskeyCredentials");
}); });
modelBuilder.Entity("Cloud.Models.UserDeck", b => modelBuilder.Entity("Model.UserDeck", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
-12
View File
@@ -1,12 +0,0 @@
namespace Cloud.Models;
public class UserDeck
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public List<string> Cards { get; set; } = [];
public List<string> Divers { get; set; } = [];
public string? Notes { get; set; }
public string? Season { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
+20
View File
@@ -1,4 +1,5 @@
using DatabaseTest.Components; using DatabaseTest.Components;
using DatabaseTest.Models;
using DatabaseTest.Services; using DatabaseTest.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -12,6 +13,25 @@ builder.Services.AddScoped<IDatabaseService, DatabaseService>();
var app = builder.Build(); var app = builder.Build();
try
{
using var scope = app.Services.CreateScope();
var configService = scope.ServiceProvider.GetRequiredService<IDatabaseConfigService>();
var dbService = scope.ServiceProvider.GetRequiredService<IDatabaseService>();
if (configService.ProviderType == DatabaseProviderType.PostgreSQL)
{
_ = dbService.ApplyMigrationsAsync();
}
else
{
_ = dbService.EnsureDatabaseCreatedAsync();
}
}
catch
{
// Ignore if database is temporarily offline on startup
}
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
@@ -404,6 +404,9 @@ public class DatabaseService : IDatabaseService
public async Task<OperationResult> SaveUserAsync(User user, bool isNew) public async Task<OperationResult> SaveUserAsync(User user, bool isNew)
{ {
user.Username = user.Username?.Trim() ?? "";
user.Password = user.Password?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(user.Username)) if (string.IsNullOrWhiteSpace(user.Username))
return OperationResult.Fail("Username is required."); return OperationResult.Fail("Username is required.");
if (string.IsNullOrWhiteSpace(user.Password)) if (string.IsNullOrWhiteSpace(user.Password))
@@ -487,11 +490,13 @@ public class DatabaseService : IDatabaseService
public async Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew) public async Task<OperationResult> SaveCardNoteAsync(CardNote note, bool isNew)
{ {
note.CardName = note.CardName?.Trim() ?? "";
note.Username = note.Username?.Trim() ?? "";
note.Note = note.Note?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(note.CardName)) if (string.IsNullOrWhiteSpace(note.CardName))
return OperationResult.Fail("Card name is required."); return OperationResult.Fail("Card name is required.");
note.Username ??= "";
try try
{ {
await using var db = _configService.CreateDbContext(); await using var db = _configService.CreateDbContext();
+32
View File
@@ -0,0 +1,32 @@
namespace Model;
public class UserDeck
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public List<string> Cards { get; set; } = [];
public List<string> Divers { get; set; } = [];
public string? Notes { get; set; }
public string? Season { get; set; } = "Season 1";
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsValid => Cards.Count == 40 &&
Divers.Count == 2 &&
Divers[0] != Divers[1] &&
!Cards.Contains(Divers[0]) &&
!Cards.Contains(Divers[1]);
public string ValidationMessage
{
get
{
if (Cards.Count < 40) return $"Deck needs {40 - Cards.Count} more card{(40 - Cards.Count == 1 ? "" : "s")}.";
if (Cards.Count > 40) return $"Deck has {Cards.Count - 40} too many cards.";
if (Divers.Count < 2) return $"Deck needs {2 - Divers.Count} more diver{(2 - Divers.Count == 1 ? "" : "s")}.";
if (Divers.Count > 2) return "Deck can only have 2 divers.";
if (Divers.Count == 2 && Divers[0] == Divers[1]) return "Diver cards must be unique.";
if (Divers.Any(d => Cards.Contains(d))) return "Diver cards cannot be in the main deck.";
return "Deck is valid.";
}
}
}
+9 -4
View File
@@ -13,10 +13,15 @@
<h1>Decks</h1> <h1>Decks</h1>
<div class="decks-header-row"> <div class="decks-header-row">
<p class="text-secondary">@decks.Count deck@(decks.Count != 1 ? "s" : "")</p> <p class="text-secondary">@decks.Count deck@(decks.Count != 1 ? "s" : "")</p>
<div class="form-check form-switch"> <div class="d-flex align-items-center gap-3">
<input class="form-check-input" type="checkbox" id="showPrecons" @bind="showPrecons" <a href="deck-builder" class="btn btn-primary btn-sm btn-create-deck">
@bind:after="RefreshDecks"> <i class="bi bi-plus-lg me-1"></i> Create Deck
<label class="form-check-label" for="showPrecons">Show Precons</label> </a>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="showPrecons" @bind="showPrecons"
@bind:after="RefreshDecks">
<label class="form-check-label" for="showPrecons">Show Precons</label>
</div>
</div> </div>
</div> </div>
</div> </div>
+127
View File
@@ -1,6 +1,8 @@
using API.Services;
using Cloud.Models; using Cloud.Models;
using DatabaseTest.Models; using DatabaseTest.Models;
using DatabaseTest.Services; using DatabaseTest.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Model; using Model;
@@ -283,4 +285,129 @@ public class DatabaseServiceTests
Assert.That(description, Does.Contain("Password=******")); Assert.That(description, Does.Contain("Password=******"));
Assert.That(description, Does.Not.Contain("secret_password")); Assert.That(description, Does.Not.Contain("secret_password"));
} }
[Test]
public async Task AddNewUser_ValidationAndTrimming_WorksCorrectly()
{
// Empty username fails
var emptyUser = new User { Username = " ", Password = "valid_password" };
var emptyRes = await _dbService.SaveUserAsync(emptyUser, true);
Assert.That(emptyRes.Success, Is.False);
Assert.That(emptyRes.Message, Does.Contain("Username is required"));
// Empty password fails
var noPassUser = new User { Username = "valid_user", Password = " " };
var noPassRes = await _dbService.SaveUserAsync(noPassUser, true);
Assert.That(noPassRes.Success, Is.False);
Assert.That(noPassRes.Message, Does.Contain("Password is required"));
// Valid user with padding gets trimmed and created
var paddedUser = new User { Username = " trimmed_user ", Password = " pass123 " };
var okRes = await _dbService.SaveUserAsync(paddedUser, true);
Assert.That(okRes.Success, Is.True);
var savedUser = await _dbService.GetUserAsync("trimmed_user");
Assert.That(savedUser, Is.Not.Null);
Assert.That(savedUser!.Username, Is.EqualTo("trimmed_user"));
Assert.That(savedUser.Password, Is.EqualTo("pass123"));
}
[Test]
public async Task AddNewNote_ValidationAndTrimming_WorksCorrectly()
{
// Empty card name fails
var emptyCard = new CardNote { CardName = " ", Username = "user1", Note = "Some note" };
var emptyRes = await _dbService.SaveCardNoteAsync(emptyCard, true);
Assert.That(emptyRes.Success, Is.False);
Assert.That(emptyRes.Message, Does.Contain("Card name is required"));
// Valid note with padding gets trimmed and created
var paddedNote = new CardNote { CardName = " Arcane Spell ", Username = " player2 ", Note = " Important note " };
var okRes = await _dbService.SaveCardNoteAsync(paddedNote, true);
Assert.That(okRes.Success, Is.True);
var savedNote = await _dbService.GetCardNoteAsync("Arcane Spell", "player2");
Assert.That(savedNote, Is.Not.Null);
Assert.That(savedNote!.CardName, Is.EqualTo("Arcane Spell"));
Assert.That(savedNote.Username, Is.EqualTo("player2"));
Assert.That(savedNote.Note, Is.EqualTo("Important note"));
}
[Test]
public void DatabaseTest_Migrations_IncludesAddUsersAndSyncCardNotes()
{
var migrations = typeof(Cloud.AppDbContext).Assembly.GetTypes()
.Where(t => typeof(Microsoft.EntityFrameworkCore.Migrations.Migration).IsAssignableFrom(t) && !t.IsAbstract)
.Select(t => t.Name)
.ToList();
Assert.That(migrations, Does.Contain("AddUsersAndSyncCardNotes"));
}
[Test]
public async Task CreateAccount_WithRandomCredentials_AgainstPostgreSql_AndTestAgainstLoginApi()
{
var config = new ConfigurationBuilder().Build();
var postgresConfigService = new DatabaseConfigService(config);
postgresConfigService.SetProvider(DatabaseProviderType.PostgreSQL);
var postgresDbService = new DatabaseService(postgresConfigService, NullLogger<DatabaseService>.Instance);
var health = await postgresDbService.CheckHealthAsync();
if (!health.IsConnected)
{
Assert.Ignore($"PostgreSQL database is not reachable at {postgresConfigService.ConnectionString}. Error: {health.ErrorMessage}");
}
await postgresDbService.ApplyMigrationsAsync();
var randomUsername = $"user_{Guid.NewGuid():N}";
var randomPassword = $"P@ss_{Guid.NewGuid():N}";
var newUser = new User
{
Username = randomUsername,
Password = randomPassword,
CreatedAt = DateTime.UtcNow
};
try
{
// 1. Add account to PostgreSQL database using DatabaseTest service
var saveResult = await postgresDbService.SaveUserAsync(newUser, true);
Assert.That(saveResult.Success, Is.True, $"Failed to create user in PostgreSQL: {saveResult.Message}");
// 2. Verify account exists in the database
var fetchedUser = await postgresDbService.GetUserAsync(randomUsername);
Assert.That(fetchedUser, Is.Not.Null);
Assert.That(fetchedUser!.Username, Is.EqualTo(randomUsername));
Assert.That(fetchedUser.Password, Is.EqualTo(randomPassword));
// 3. Test against Login API using API's AppDbContext connected to the same PostgreSQL DB
var apiDbOptions = new DbContextOptionsBuilder<API.Data.AppDbContext>()
.UseNpgsql(postgresConfigService.ConnectionString)
.Options;
await using var apiDb = new API.Data.AppDbContext(apiDbOptions);
var tokenService = new TokenService();
var mutation = new API.GraphQL.Mutation();
// Test login with incorrect password
var invalidLogin = await mutation.Login(apiDb, tokenService, randomUsername, "wrong_" + randomPassword);
Assert.That(invalidLogin.Success, Is.False, "Login should fail with invalid password");
Assert.That(invalidLogin.Token, Is.Null);
// Test login with correct password
var validLogin = await mutation.Login(apiDb, tokenService, randomUsername, randomPassword);
Assert.That(validLogin.Success, Is.True, $"Login failed: {validLogin.Message}");
Assert.That(validLogin.Username, Is.EqualTo(randomUsername));
Assert.That(validLogin.Token, Is.Not.Null.And.Not.Empty);
var validatedUsername = tokenService.ValidateToken(validLogin.Token);
Assert.That(validatedUsername, Is.EqualTo(randomUsername));
}
finally
{
// Cleanup test user
await postgresDbService.DeleteUserAsync(randomUsername);
}
}
} }
+290
View File
@@ -0,0 +1,290 @@
using System.Net;
using API.Data;
using API.GraphQL;
using API.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Model;
using Web.Services;
namespace Tests;
[TestFixture]
public class DeckBuilderServiceTests
{
private AppDbContext _db = null!;
private ITokenService _tokenService = null!;
[SetUp]
public void Setup()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new AppDbContext(options);
_tokenService = new TokenService();
_db.Users.Add(new User { Username = "player1", Password = "password123", CreatedAt = DateTime.UtcNow });
_db.SaveChanges();
}
[TearDown]
public void TearDown()
{
_db.Database.EnsureDeleted();
_db.Dispose();
}
[Test]
public void UserDeck_ValidationRules_AreEnforcedCorrectly()
{
var deck = new UserDeck
{
Name = "My Test Deck",
Season = "Season 1",
Notes = "Test notes"
};
// Initially empty -> invalid
Assert.That(deck.IsValid, Is.False);
Assert.That(deck.ValidationMessage, Does.Contain("needs 40 more cards"));
// Add 40 cards (13x3 + 1x1)
for (int i = 0; i < 13; i++)
{
deck.Cards.Add($"Card_{i}");
deck.Cards.Add($"Card_{i}");
deck.Cards.Add($"Card_{i}");
}
deck.Cards.Add("Card_13");
Assert.That(deck.Cards.Count, Is.EqualTo(40));
Assert.That(deck.IsValid, Is.False); // Still needs 2 divers
Assert.That(deck.ValidationMessage, Does.Contain("needs 2 more divers"));
// Add 1 diver
deck.Divers.Add("Diver_1");
Assert.That(deck.IsValid, Is.False);
Assert.That(deck.ValidationMessage, Does.Contain("needs 1 more diver"));
// Add duplicate diver -> invalid
deck.Divers.Add("Diver_1");
Assert.That(deck.IsValid, Is.False);
Assert.That(deck.ValidationMessage, Does.Contain("must be unique"));
// Change second diver to a card that is in the deck -> invalid
deck.Divers[1] = "Card_0";
Assert.That(deck.IsValid, Is.False);
Assert.That(deck.ValidationMessage, Does.Contain("cannot be in the main deck"));
// Change second diver to a unique card not in deck -> valid!
deck.Divers[1] = "Diver_2";
Assert.That(deck.IsValid, Is.True);
Assert.That(deck.ValidationMessage, Is.EqualTo("Deck is valid."));
}
[Test]
public async Task GraphQL_SaveDeck_And_GetDecks_WorksEndToEnd()
{
var token = _tokenService.GenerateToken("player1");
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["Authorization"] = $"Bearer {token}";
var accessor = new HttpContextAccessor { HttpContext = httpContext };
var mutation = new Mutation();
var query = new Query();
var input = new UserDeckInput(
Id: Guid.NewGuid(),
Name: "Aggro Champions",
Cards: ["CardA", "CardA", "CardA", "CardB"],
Divers: ["DiverA", "DiverB"],
Notes: "Early aggression deck notes",
Season: "Season 1"
);
var savedDeck = await mutation.SaveDeck(_db, accessor, _tokenService, input);
Assert.That(savedDeck, Is.Not.Null);
Assert.That(savedDeck.Name, Is.EqualTo("Aggro Champions"));
Assert.That(savedDeck.Notes, Is.EqualTo("Early aggression deck notes"));
Assert.That(savedDeck.Season, Is.EqualTo("Season 1"));
Assert.That(savedDeck.Cards.Count, Is.EqualTo(4));
Assert.That(savedDeck.Divers.Count, Is.EqualTo(2));
var fetchedDecks = await query.GetDecks(_db, accessor, _tokenService);
Assert.That(fetchedDecks.Count, Is.EqualTo(1));
Assert.That(fetchedDecks[0].Name, Is.EqualTo("Aggro Champions"));
var singleDeck = await query.GetDeck(_db, accessor, _tokenService, savedDeck.Id);
Assert.That(singleDeck, Is.Not.Null);
Assert.That(singleDeck!.Name, Is.EqualTo("Aggro Champions"));
var deleted = await mutation.DeleteDeck(_db, accessor, _tokenService, savedDeck.Id);
Assert.That(deleted, Is.True);
var remainingDecks = await query.GetDecks(_db, accessor, _tokenService);
Assert.That(remainingDecks, Is.Empty);
}
[Test]
public async Task UserDecksService_SaveAndGetDecks_WithMockHttp()
{
var mockHttp = new HttpClient(new TestHttpMessageHandler(req =>
{
var content = req.Content?.ReadAsStringAsync().Result ?? "";
if (content.Contains("SaveDeck") || (req.Method == HttpMethod.Post && req.RequestUri?.AbsolutePath == "/api/decks"))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(@"{
""data"": {
""saveDeck"": {
""id"": ""3fa85f64-5717-4562-b3fc-2c963f66afa6"",
""name"": ""Test GraphQL Deck"",
""cards"": [""Card1"", ""Card2""],
""divers"": [""Diver1"", ""Diver2""],
""notes"": ""Great deck"",
""season"": ""Season 1"",
""updatedAt"": ""2026-08-17T00:00:00Z""
}
}
}")
};
}
if (content.Contains("GetDecks") || (req.Method == HttpMethod.Get && req.RequestUri?.AbsolutePath == "/api/decks"))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(@"{
""data"": {
""decks"": [
{
""id"": ""3fa85f64-5717-4562-b3fc-2c963f66afa6"",
""name"": ""Test GraphQL Deck"",
""cards"": [""Card1"", ""Card2""],
""divers"": [""Diver1"", ""Diver2""],
""notes"": ""Great deck"",
""season"": ""Season 1"",
""updatedAt"": ""2026-08-17T00:00:00Z""
}
]
}
}")
};
}
return new HttpResponseMessage(HttpStatusCode.BadRequest);
}));
var networkStatus = new NetworkStatusService(new DummyJsRuntime(true));
await networkStatus.InitializeAsync();
var sp = new ServiceCollection().BuildServiceProvider();
var authService = new AuthService(mockHttp, networkStatus, sp);
authService.SetAuth("player1", "test-token");
var service = new UserDecksService(mockHttp, authService, networkStatus);
var saved = await service.SaveDeckAsync(new UserDeck
{
Id = Guid.Parse("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
Name = "Test GraphQL Deck",
Cards = ["Card1", "Card2"],
Divers = ["Diver1", "Diver2"],
Notes = "Great deck",
Season = "Season 1"
});
Assert.That(saved, Is.Not.Null);
Assert.That(saved!.Name, Is.EqualTo("Test GraphQL Deck"));
Assert.That(saved.Season, Is.EqualTo("Season 1"));
Assert.That(saved.Notes, Is.EqualTo("Great deck"));
var decks = await service.GetDecksAsync();
Assert.That(decks, Is.Not.Empty);
Assert.That(decks[0].Name, Is.EqualTo("Test GraphQL Deck"));
}
[Test]
public void DeckBuilder_CardSelection_ExcludesImmortalizedCards()
{
var sampleCards = new List<CardData>
{
new() { Name = "Card1", Category = "Agent" },
new() { Name = "Card2", Category = "Spell" },
new() { Name = "Card3", Category = "Immortalized" },
new() { Name = "Card4", Category = "Agent", ImmortalizeFrom = "BaseCard" },
new() { Name = "Card5", Category = "Artifact" }
};
var selectableCards = sampleCards
.Where(c => (c.Category is "Agent" or "Spell") && !c.IsImmortalized)
.ToList();
Assert.That(selectableCards.Select(c => c.Name), Is.EquivalentTo(new[] { "Card1", "Card2" }));
}
[Test]
public void DeckBuilder_DiverSelection_ExcludesImmortalizedCardsAndDeckCards()
{
var sampleCards = new List<CardData>
{
new() { Name = "Card1", Category = "Agent" },
new() { Name = "Card2", Category = "Spell" },
new() { Name = "Card3", Category = "Immortalized" },
new() { Name = "Card4", Category = "Agent", ImmortalizeFrom = "BaseCard" },
new() { Name = "Card5", Category = "Agent" }
};
var deckCards = new List<string> { "Card1" };
var otherDiver = "Card5";
var availableDivers = sampleCards
.Where(c => (c.Category is "Agent" or "Spell") && !c.IsImmortalized)
.Where(c => !deckCards.Contains(c.Name))
.Where(c => c.Name != otherDiver)
.ToList();
Assert.That(availableDivers.Select(c => c.Name), Is.EquivalentTo(new[] { "Card2" }));
}
private class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}
private class DummyJsRuntime : IJSRuntime
{
private readonly bool _online;
public DummyJsRuntime(bool online)
{
_online = online;
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
{
if (identifier == "networkStatus.initialize" && typeof(TValue) == typeof(bool))
return ValueTask.FromResult((TValue)(object)_online);
return ValueTask.FromResult(default(TValue)!);
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken,
object?[]? args)
{
return InvokeAsync<TValue>(identifier, args);
}
}
}
+30 -1
View File
@@ -271,6 +271,35 @@ public class GraphQLTests
Assert.That(saved, Is.True); Assert.That(saved, Is.True);
} }
[Test]
public async Task AuthService_LoginAsync_SendsRequestToPort7266Url()
{
Uri? requestedUri = null;
var mockHttp = new HttpClient(new TestHttpMessageHandler(req =>
{
requestedUri = req.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
@"{""data"":{""login"":{""success"":true,""token"":""test_jwt"",""username"":""player1"",""message"":""Login successful""}}}")
};
}));
var networkStatus = new NetworkStatusService(new DummyJsRuntime(true));
await networkStatus.InitializeAsync();
var sp = new ServiceCollection().BuildServiceProvider();
var authService = new AuthService(mockHttp, networkStatus, sp);
var result = await authService.LoginAsync("player1", "password123");
Assert.That(result.Success, Is.True);
Assert.That(authService.IsLoggedIn, Is.True);
Assert.That(requestedUri, Is.Not.Null);
Assert.That(requestedUri!.Port, Is.EqualTo(7266));
Assert.That(requestedUri.ToString(), Does.Contain("7266/graphql"));
}
[Test] [Test]
public void AppDbContext_Model_ConfiguresRequiredEntities() public void AppDbContext_Model_ConfiguresRequiredEntities()
{ {
@@ -280,7 +309,7 @@ public class GraphQLTests
var usersEntity = _db.Model.FindEntityType(typeof(User)); var usersEntity = _db.Model.FindEntityType(typeof(User));
Assert.That(usersEntity, Is.Not.Null); Assert.That(usersEntity, Is.Not.Null);
var userDecksEntity = _db.Model.FindEntityType(typeof(API.Models.UserDeck)); var userDecksEntity = _db.Model.FindEntityType(typeof(Model.UserDeck));
Assert.That(userDecksEntity, Is.Not.Null); Assert.That(userDecksEntity, Is.Not.Null);
} }
@@ -38,6 +38,19 @@
<i class="bi bi-journal-text nav-icon"></i> Decks <i class="bi bi-journal-text nav-icon"></i> Decks
</NavLink> </NavLink>
</div> </div>
@if (Auth.IsLoggedIn)
{
<div class="nav-item px-3">
<NavLink class="nav-link" href="my-decks">
<i class="bi bi-collection-play-fill 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"> <div class="nav-item px-3">
<NavLink class="nav-link" href="syndicates"> <NavLink class="nav-link" href="syndicates">
<i class="bi bi-diagram-2-fill nav-icon"></i> Syndicates <i class="bi bi-diagram-2-fill nav-icon"></i> Syndicates
+719
View File
@@ -0,0 +1,719 @@
@page "/deck-builder"
@page "/deck-builder/{DeckId:guid}"
@using Model
@using Web.Services
@using Shared.Components
@inject UserDecksService UserDecksService
@inject AuthService AuthService
@inject NavigationManager Navigation
@inject NetworkStatusService NetworkStatus
@implements IDisposable
<PageTitle>Chrono CCG - @(string.IsNullOrEmpty(deck.Name) ? "Deck Builder" : deck.Name)</PageTitle>
<HeadContent>
<meta name="description" content="Build and customize your Chrono CCG deck with 40 cards, 2 diver cards, notes, and season tracking." />
</HeadContent>
<div class="deck-builder-page container-fluid px-2 px-lg-4 py-3">
@if (!AuthService.IsLoggedIn)
{
<div class="auth-required-card glass-panel p-4 p-md-5 text-center my-5 mx-auto" style="max-width: 580px;">
<div class="mb-3">
<i class="bi bi-shield-lock-fill auth-lock-icon"></i>
</div>
<h2 class="fw-bold text-white mb-2">Authentication Required</h2>
<p class="text-secondary mb-4 px-md-3">
Log in to create, customize, and save your Chrono CCG decks to your cloud profile.
</p>
<div>
<a href="login" class="btn save-btn px-4 py-2">
<i class="bi bi-box-arrow-in-right"></i> Log In to Continue
</a>
</div>
</div>
}
else
{
<!-- Header / Controls Bar -->
<div class="builder-header-bar glass-panel p-3 p-md-4 mb-3">
<div class="row align-items-center g-3">
<div class="col-12 col-md-4 col-lg-3">
<label class="field-label">
<i class="bi bi-pencil-square text-primary"></i> Deck Name
</label>
<input type="text" class="form-control deck-name-input input-glow"
placeholder="Enter Deck Name..." @bind="deck.Name" @bind:event="oninput" />
</div>
<div class="col-6 col-md-3 col-lg-2">
<label class="field-label">
<i class="bi bi-trophy text-warning"></i> Season
</label>
<input type="text" class="form-control deck-season-input input-glow"
placeholder="e.g. Season 1" @bind="deck.Season" @bind:event="oninput" />
</div>
<div class="col-6 col-md-5 col-lg-4 d-flex align-items-center status-pill-group pt-md-3">
<div>
<span class="card-count @(deck.Cards.Count == 40 ? "count-complete" : deck.Cards.Count > 40 ? "count-overflow" : "count-progress")"
title="Main deck must have exactly 40 cards">
<i class="bi @(deck.Cards.Count == 40 ? "bi-check-circle-fill" : "bi-layers-fill")"></i>
@deck.Cards.Count / 40
</span>
</div>
<div>
<span class="validity-badge @(deck.IsValid ? "valid" : "invalid")" title="@deck.ValidationMessage">
<i class="bi @(deck.IsValid ? "bi-shield-check" : "bi-exclamation-triangle-fill")"></i>
@(deck.IsValid ? "Valid" : deck.ValidationMessage)
</span>
</div>
</div>
<div class="col-12 col-lg-3 d-flex align-items-center justify-content-lg-end gap-2 pt-lg-3">
<button class="save-btn" @onclick="SaveDeckAsync" disabled="@isSaving">
@if (isSaving)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
<span>Saving...</span>
}
else
{
<i class="bi bi-floppy2-fill"></i>
<span>Save Deck</span>
}
</button>
<a href="my-decks" class="btn-ghost-secondary">
<i class="bi bi-collection-play"></i> My Decks
</a>
</div>
</div>
<!-- Notes and Last Updated Row -->
<div class="row mt-3 pt-3 border-top border-secondary border-opacity-25 align-items-center g-2">
<div class="col-12 col-md-8">
<div class="input-group input-group-sm">
<span class="input-group-text bg-dark border-secondary border-opacity-50 text-secondary">
<i class="bi bi-sticky-fill text-warning me-1"></i> Strategy Notes
</span>
<input type="text" class="form-control deck-notes-input input-glow"
placeholder="Add strategy, combo ideas, or match-up notes..." @bind="deck.Notes" @bind:event="oninput" />
</div>
</div>
<div class="col-12 col-md-4 text-md-end text-secondary small deck-updated-at">
<i class="bi bi-clock-history me-1 text-primary"></i> Last updated: @deck.UpdatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger py-2 px-3 small mb-3 alert-dismissible fade show glass-panel border-danger text-danger-emphasis d-flex align-items-center" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2 fs-5 text-danger"></i>
<div class="flex-grow-1">@errorMessage</div>
<button type="button" class="btn-close" @onclick="() => errorMessage = null"></button>
</div>
}
<!-- Builder Main Workspace: Browser on Left, Deck List & Analytics on Right -->
<div class="row g-3">
<!-- Left: Card Browser -->
<div class="col-12 col-lg-8">
<div class="glass-panel p-3 p-md-4 h-100 d-flex flex-column">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h5 class="fw-bold text-white mb-0 d-flex align-items-center gap-2">
<i class="bi bi-grid-3x3-gap-fill text-primary"></i> Card Browser
</h5>
<div class="small text-secondary">
<span class="text-white-50">Showing @FilteredBrowserCards.Count() of @allCards.Count(c => (c.Category is "Agent" or "Spell") && !c.IsImmortalized) cards</span>
<span class="mx-1">•</span>
<span>Click card to add (max 3)</span>
</div>
</div>
<!-- Search and Filters -->
<div class="row g-2 mb-3 filter-search-group">
<div class="col-12 col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control input-glow"
placeholder="Search name, text, archetype..." @bind="searchFilter" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(searchFilter))
{
<button class="btn btn-outline-secondary" @onclick="() => searchFilter = string.Empty"><i class="bi bi-x"></i></button>
}
</div>
</div>
<div class="col-6 col-md-3">
<select class="form-select form-select-sm filter-select" @bind="categoryFilter">
<option value="">All Categories</option>
<option value="Agent">Agents</option>
<option value="Spell">Spells</option>
</select>
</div>
<div class="col-6 col-md-3">
<select class="form-select form-select-sm filter-select" @bind="syndicateFilter">
<option value="">All Syndicates</option>
@foreach (var syn in syndicates)
{
<option value="@syn">@syn</option>
}
</select>
</div>
<div class="col-12 col-md-2 d-flex gap-1">
<select class="form-select form-select-sm filter-select flex-grow-1" @bind="costFilter">
<option value="">Cost</option>
@for (int i = 0; i <= 13; i++)
{
<option value="@i.ToString()">Cost @i</option>
}
</select>
@if (!string.IsNullOrEmpty(searchFilter) || !string.IsNullOrEmpty(categoryFilter) || !string.IsNullOrEmpty(syndicateFilter) || !string.IsNullOrEmpty(costFilter))
{
<button class="btn btn-filter-clear" @onclick="ResetFilters" title="Reset all filters">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
}
</div>
</div>
<!-- Card Grid -->
<div class="card-browser-grid flex-grow-1 overflow-auto">
@foreach (var card in FilteredBrowserCards)
{
var countInDeck = deck.Cards.Count(c => c == card.Name);
var isDiver = deck.Divers.Contains(card.Name);
var isMaxed = countInDeck >= 3 || deck.Cards.Count >= 40 || isDiver;
var catClass = card.IsAgent ? "card-agent" : card.IsSpell ? "card-spell" : card.IsImmortalized ? "card-immortalized" : "";
<div class="browser-card @catClass @(countInDeck > 0 ? "in-deck" : "") @(isMaxed ? "max-copies" : "cursor-pointer")"
@onclick="() => AddCardToDeck(card)">
<div class="card-img-wrapper">
<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=%22140%22 height=%22190%22><rect fill=%22%231a1a36%22 width=%22140%22 height=%22190%22/><text fill=%22%238888aa%22 font-size=%2212%22 x=%2270%22 y=%2295%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22>Card</text></svg>'" />
@if (card.Cost.HasValue)
{
<div class="cost-gem" title="Energy Cost: @card.Cost">
@card.Cost
</div>
}
@if (countInDeck > 0)
{
<div class="deck-count-badge @(countInDeck == 1 ? "count-1" : countInDeck == 2 ? "count-2" : "count-3")">
@(countInDeck == 3 ? "3 MAX" : $"{countInDeck}/3")
</div>
}
@if (isDiver)
{
<div class="diver-ribbon">
<i class="bi bi-shuffle"></i> DIVER
</div>
}
</div>
<div class="card-mini-info">
<div class="card-title-text" title="@card.Name">@card.Name</div>
<div class="card-stats-row">
<span class="card-cat-label @(card.IsAgent ? "cat-agent" : card.IsSpell ? "cat-spell" : "cat-immortalized")">
@card.Category
</span>
@if (card.Attack.HasValue && card.Health.HasValue)
{
<span class="combat-stats-chip" title="Attack / Health">
<span class="atk-val"><i class="bi bi-suit-spade-fill"></i>@card.Attack</span>
<span class="text-secondary opacity-50">/</span>
<span class="hp-val"><i class="bi bi-shield-fill"></i>@card.Health</span>
</span>
}
else if (!string.IsNullOrEmpty(card.Syndicate))
{
<span class="d-inline-flex align-items-center gap-1" style="font-size: 0.68rem;">
<span class="syndicate-dot @GetSyndicateDotClass(card.Syndicate)"></span>
<span class="text-white-50 text-truncate" style="max-width: 60px;">@card.Syndicate</span>
</span>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Right: Current Deck, Diver Slots, and Mana Curve -->
<div class="col-12 col-lg-4">
<div class="glass-panel p-3 p-md-4 h-100 d-flex flex-column gap-3">
<!-- Diver Slots Section -->
<div class="divers-section">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="fw-bold text-white mb-0 d-flex align-items-center gap-2">
<i class="bi bi-shuffle text-warning"></i> Diver Cards (2 Required)
</h6>
<span class="badge @(deck.Divers.Count == 2 && deck.Divers[0] != deck.Divers[1] && !deck.Cards.Contains(deck.Divers[0]) && !deck.Cards.Contains(deck.Divers[1]) ? "bg-success" : "bg-secondary")">
@deck.Divers.Count / 2
</span>
</div>
<div class="row g-2">
<!-- Diver Slot 0 -->
<div class="col-6">
<div class="diver-slot @(deck.Divers.Count > 0 && !string.IsNullOrEmpty(deck.Divers[0]) ? "occupied-slot" : "empty-slot")"
@onclick="() => OpenDiverPicker(0)">
@if (deck.Divers.Count > 0 && !string.IsNullOrEmpty(deck.Divers[0]))
{
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="diver-badge-gold">Diver 1</span>
<button type="button" class="diver-remove-btn" title="Remove Diver"
@onclick:stopPropagation="true" @onclick="() => RemoveDiver(0)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
<div class="deck-row-title" title="@deck.Divers[0]">
@deck.Divers[0]
</div>
}
else
{
<div class="text-center py-2 text-secondary small">
<i class="bi bi-plus-circle d-block mb-1 text-warning fs-5"></i>
<span class="text-white-50">Select Diver 1</span>
</div>
}
</div>
</div>
<!-- Diver Slot 1 -->
<div class="col-6">
<div class="diver-slot @(deck.Divers.Count > 1 && !string.IsNullOrEmpty(deck.Divers[1]) ? "occupied-slot" : "empty-slot")"
@onclick="() => OpenDiverPicker(1)">
@if (deck.Divers.Count > 1 && !string.IsNullOrEmpty(deck.Divers[1]))
{
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="diver-badge-gold">Diver 2</span>
<button type="button" class="diver-remove-btn" title="Remove Diver"
@onclick:stopPropagation="true" @onclick="() => RemoveDiver(1)">
<i class="bi bi-x-circle-fill"></i>
</button>
</div>
<div class="deck-row-title" title="@deck.Divers[1]">
@deck.Divers[1]
</div>
}
else
{
<div class="text-center py-2 text-secondary small">
<i class="bi bi-plus-circle d-block mb-1 text-warning fs-5"></i>
<span class="text-white-50">Select Diver 2</span>
</div>
}
</div>
</div>
</div>
</div>
<!-- Deck Quick Stats Breakdown -->
@if (deck.Cards.Any())
{
<div class="deck-stats-bar">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-1 mb-2">
<div class="d-flex flex-wrap gap-1">
<span class="stats-chip chip-agent" title="Agent Cards">
<i class="bi bi-person-badge-fill"></i> @AgentsCount
</span>
<span class="stats-chip chip-spell" title="Spell Cards">
<i class="bi bi-magic"></i> @SpellsCount
</span>
<span class="stats-chip chip-immortalized" title="Immortalized Cards">
<i class="bi bi-stars"></i> @ImmortalizedCount
</span>
</div>
@if (DeckSyndicates.Any())
{
<div class="d-flex align-items-center gap-1" title="Included Syndicates">
@foreach (var syn in DeckSyndicates)
{
<span class="syndicate-dot @GetSyndicateDotClass(syn)" title="@syn"></span>
}
</div>
}
</div>
<div class="mana-curve-wrapper pt-1">
<ManaCurve Distribution="GetManaDistribution()" MaxCostLabel="8" />
</div>
</div>
}
<!-- Main Deck Cards List Section -->
<div class="deck-cards-section flex-grow-1 d-flex flex-column">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="fw-bold text-white mb-0 d-flex align-items-center gap-2">
<i class="bi bi-stack text-primary"></i> Main Deck
</h6>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-outline-danger py-0 px-2" @onclick="ClearDeck" disabled="@(!deck.Cards.Any())" title="Clear all cards">
<i class="bi bi-trash"></i> Clear
</button>
<span class="badge bg-secondary">
@GroupedDeckCards.Count unique
</span>
</div>
</div>
@if (!deck.Cards.Any())
{
<div class="empty-deck-box flex-grow-1 d-flex flex-column align-items-center justify-content-center">
<i class="bi bi-inboxes empty-deck-icon"></i>
<h6 class="text-white mb-1">Deck is Empty</h6>
<p class="text-secondary small mb-0">Click cards in the browser to add them to your deck.</p>
</div>
}
else
{
<div class="deck-card-list flex-grow-1 overflow-auto">
@foreach (var group in GroupedDeckCards)
{
var cardData = allCards.FirstOrDefault(c => c.Name == group.Name);
var rowCatClass = cardData?.IsAgent == true ? "row-agent" : cardData?.IsSpell == true ? "row-spell" : cardData?.IsImmortalized == true ? "row-immortalized" : "";
<div class="deck-row-item @rowCatClass" @key="group.Name">
<div class="d-flex align-items-center gap-2 text-truncate pe-2">
@if (cardData?.Cost.HasValue == true)
{
<span class="mini-cost-pill">@cardData.Cost</span>
}
<span class="deck-row-title" title="@group.Name">@group.Name</span>
</div>
<div class="quantity-stepper">
<button class="stepper-btn" @onclick="() => RemoveCardCopy(group.Name)" title="Remove copy">-</button>
<span class="qty-pill @(group.Count == 1 ? "qty-1" : group.Count == 2 ? "qty-2" : "qty-3")">@group.Count</span>
<button class="stepper-btn"
@onclick="() => AddCardCopy(group.Name)"
disabled="@(group.Count >= 3 || deck.Cards.Count >= 40)"
title="Add copy">+</button>
</div>
</div>
}
</div>
}
</div>
</div>
</div>
</div>
<!-- Diver Picker Modal Dialog -->
@if (showDiverPicker)
{
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.8); z-index: 1050;">
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content modal-glass text-white">
<div class="modal-header border-secondary border-opacity-25 pb-3">
<h5 class="modal-title fw-bold d-flex align-items-center gap-2">
<i class="bi bi-shuffle text-warning"></i> Select Diver @(activeDiverSlot + 1)
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseDiverPicker"></button>
</div>
<div class="modal-body py-3">
<div class="alert alert-info py-2 px-3 small mb-3 border-info border-opacity-25 bg-info bg-opacity-10 text-info">
<i class="bi bi-info-circle-fill me-1"></i> Diver cards must be unique and cannot be cards that are currently in your main deck.
</div>
<div class="input-group mb-3 filter-search-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control input-glow"
placeholder="Filter eligible diver cards..." @bind="diverSearch" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(diverSearch))
{
<button class="btn btn-outline-secondary" @onclick="() => diverSearch = string.Empty"><i class="bi bi-x"></i></button>
}
</div>
<div class="diver-picker-grid overflow-auto">
@{
var availableDivers = GetAvailableDivers();
}
@if (!availableDivers.Any())
{
<div class="w-100 text-center py-4 text-secondary">
No eligible diver cards match your filter.
</div>
}
else
{
@foreach (var card in availableDivers)
{
<div class="diver-picker-card"
@onclick="() => SelectDiver(card.Name)">
<div class="fw-bold small text-truncate text-white mb-1" title="@card.Name">@card.Name</div>
<div class="text-secondary small d-flex justify-content-between align-items-center" style="font-size: 0.75rem;">
<span class="card-cat-label @(card.IsAgent ? "cat-agent" : card.IsSpell ? "cat-spell" : "cat-immortalized")">@card.Category</span>
<span class="mini-cost-pill" style="width: 18px; height: 18px; font-size: 0.65rem;">@(card.Cost?.ToString() ?? "—")</span>
</div>
@if (!string.IsNullOrEmpty(card.Syndicate))
{
<div class="d-flex align-items-center gap-1 mt-1" style="font-size: 0.7rem;">
<span class="syndicate-dot @GetSyndicateDotClass(card.Syndicate)" style="width: 7px; height: 7px;"></span>
<span class="text-white-50 text-truncate">@card.Syndicate</span>
</div>
}
</div>
}
}
</div>
</div>
<div class="modal-footer border-secondary border-opacity-25 pt-2">
<button type="button" class="btn btn-secondary px-3" @onclick="CloseDiverPicker">Cancel</button>
</div>
</div>
</div>
</div>
}
<!-- Save Toast Alert -->
@if (showSaveToast)
{
<div class="save-toast position-fixed bottom-0 end-0 m-4 shadow-lg d-flex align-items-center gap-3"
role="alert">
<i class="bi bi-check-circle-fill fs-4"></i>
<div>
<div class="fw-bold">Saved!</div>
<div class="small opacity-75">Deck updated successfully in database.</div>
</div>
<button type="button" class="btn-close btn-close-white ms-2" @onclick="() => showSaveToast = false"></button>
</div>
}
}
</div>
@code {
[Parameter] public Guid? DeckId { get; set; }
private UserDeck deck = new() { Name = "New Deck", Season = "Season 1" };
private List<CardData> allCards = [];
private List<string> syndicates = [];
private string searchFilter = "";
private string categoryFilter = "";
private string syndicateFilter = "";
private string costFilter = "";
private string diverSearch = "";
private bool showDiverPicker;
private int activeDiverSlot = 0;
private bool isSaving;
private bool showSaveToast;
private string? errorMessage;
private record GroupedCard(string Name, int Count);
private List<GroupedCard> GroupedDeckCards => deck.Cards
.GroupBy(c => c)
.Select(g => new GroupedCard(g.Key, g.Count()))
.OrderBy(g => g.Name)
.ToList();
private IEnumerable<CardData> FilteredBrowserCards => allCards
.Where(c => (c.Category is "Agent" or "Spell") && !c.IsImmortalized)
.Where(c => string.IsNullOrWhiteSpace(searchFilter) || c.MatchesSearch(searchFilter))
.Where(c => string.IsNullOrEmpty(categoryFilter) || c.Category == categoryFilter)
.Where(c => string.IsNullOrEmpty(syndicateFilter) || c.Syndicate == syndicateFilter)
.Where(c => string.IsNullOrEmpty(costFilter) || c.Cost?.ToString() == costFilter)
.OrderBy(c => c.Cost ?? 99)
.ThenBy(c => c.Name);
private int AgentsCount => deck.Cards.Count(name => allCards.FirstOrDefault(c => c.Name == name)?.Category == "Agent");
private int SpellsCount => deck.Cards.Count(name => allCards.FirstOrDefault(c => c.Name == name)?.Category == "Spell");
private int ImmortalizedCount => deck.Cards.Count(name => allCards.FirstOrDefault(c => c.Name == name)?.Category == "Immortalized");
private List<string> DeckSyndicates => deck.Cards
.Select(name => allCards.FirstOrDefault(c => c.Name == name)?.Syndicate)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct()
.ToList()!;
private List<(int Cost, int Count)> GetManaDistribution()
{
var costs = deck.Cards
.Select(name => allCards.FirstOrDefault(card => card.Name == name)?.Cost ?? 0)
.ToList();
var maxCost = 8;
return Enumerable.Range(0, maxCost + 1)
.Select(cost => (Cost: cost, Count: costs.Count(c => c == cost)))
.ToList();
}
private static string GetSyndicateDotClass(string? syndicate)
{
return syndicate?.ToLowerInvariant() switch
{
"singularity" => "syn-singularity",
"phasetide" => "syn-phasetide",
"silence" => "syn-silence",
"lifeblood" => "syn-lifeblood",
"sungrace" => "syn-sungrace",
"splintergleam" => "syn-splintergleam",
_ => ""
};
}
private void ResetFilters()
{
searchFilter = "";
categoryFilter = "";
syndicateFilter = "";
costFilter = "";
}
protected override async Task OnInitializedAsync()
{
allCards = CardDatabase.Cards;
syndicates = allCards
.Select(c => c.Syndicate)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct()
.OrderBy(s => s)
.ToList()!;
AuthService.AuthStateChanged += OnAuthStateChanged;
await AuthService.InitializeAsync();
}
protected override async Task OnParametersSetAsync()
{
if (DeckId.HasValue && DeckId.Value != Guid.Empty)
{
if (deck.Id != DeckId.Value)
{
var loaded = await UserDecksService.GetDeckAsync(DeckId.Value);
if (loaded != null)
{
deck = loaded;
}
}
}
}
private void OnAuthStateChanged()
{
InvokeAsync(StateHasChanged);
}
private void AddCardToDeck(CardData card)
{
if (deck.Cards.Count >= 40) return;
if (deck.Cards.Count(c => c == card.Name) >= 3) return;
if (deck.Divers.Contains(card.Name)) return;
deck.Cards.Add(card.Name);
}
private void AddCardCopy(string cardName)
{
if (deck.Cards.Count >= 40) return;
if (deck.Cards.Count(c => c == cardName) >= 3) return;
if (deck.Divers.Contains(cardName)) return;
deck.Cards.Add(cardName);
}
private void RemoveCardCopy(string cardName)
{
deck.Cards.Remove(cardName);
}
private void ClearDeck()
{
deck.Cards.Clear();
}
private void OpenDiverPicker(int slot)
{
activeDiverSlot = slot;
diverSearch = "";
showDiverPicker = true;
}
private void CloseDiverPicker()
{
showDiverPicker = false;
}
private List<CardData> GetAvailableDivers()
{
var otherDiver = (activeDiverSlot == 0 && deck.Divers.Count > 1)
? deck.Divers[1]
: (activeDiverSlot == 1 && deck.Divers.Count > 0)
? deck.Divers[0]
: null;
return allCards
.Where(c => (c.Category is "Agent" or "Spell") && !c.IsImmortalized)
.Where(c => !deck.Cards.Contains(c.Name)) // Diver cannot be in deck
.Where(c => string.IsNullOrEmpty(otherDiver) || c.Name != otherDiver) // Divers must be unique
.Where(c => string.IsNullOrWhiteSpace(diverSearch) || c.MatchesSearch(diverSearch))
.OrderBy(c => c.Name)
.ToList();
}
private void SelectDiver(string cardName)
{
while (deck.Divers.Count <= activeDiverSlot)
{
deck.Divers.Add("");
}
deck.Divers[activeDiverSlot] = cardName;
deck.Divers = deck.Divers.Where(d => !string.IsNullOrEmpty(d)).ToList();
showDiverPicker = false;
}
private void RemoveDiver(int slot)
{
if (slot < deck.Divers.Count)
{
deck.Divers.RemoveAt(slot);
}
}
private async Task SaveDeckAsync()
{
if (string.IsNullOrWhiteSpace(deck.Name))
{
deck.Name = "Untitled Deck";
}
isSaving = true;
errorMessage = null;
try
{
var saved = await UserDecksService.SaveDeckAsync(deck);
if (saved != null)
{
var isNew = !DeckId.HasValue || DeckId.Value == Guid.Empty;
deck.Id = saved.Id;
deck.UpdatedAt = saved.UpdatedAt;
showSaveToast = true;
if (isNew || DeckId != saved.Id)
{
DeckId = saved.Id;
Navigation.NavigateTo($"/deck-builder/{saved.Id}");
}
}
else
{
errorMessage = "Failed to save deck to database.";
}
}
catch (Exception ex)
{
errorMessage = $"Error saving deck: {ex.Message}";
}
finally
{
isSaving = false;
}
}
public void Dispose()
{
AuthService.AuthStateChanged -= OnAuthStateChanged;
}
}
+840
View File
@@ -0,0 +1,840 @@
/* ═════════════════════════════════════════════════════════════════════
CHRONO CCG - DECK BUILDER STYLES
═════════════════════════════════════════════════════════════════════ */
.deck-builder-page {
max-width: 1600px;
margin: 0 auto;
position: relative;
min-height: calc(100vh - 70px);
}
/* Ambient glow in background */
.deck-builder-page::before {
content: '';
position: fixed;
top: -10%;
left: 20%;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(108, 99, 255, 0.08) 0%, rgba(108, 99, 255, 0) 70%);
pointer-events: none;
z-index: 0;
}
.deck-builder-page::after {
content: '';
position: fixed;
bottom: -10%;
right: 15%;
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(255, 215, 0, 0.05) 0%, rgba(255, 215, 0, 0) 70%);
pointer-events: none;
z-index: 0;
}
/* ── Glassmorphism Panels ── */
.glass-panel {
background: rgba(20, 20, 40, 0.85);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: relative;
z-index: 1;
}
/* ── Auth Required Card ── */
.auth-required-card {
background: linear-gradient(145deg, rgba(24, 24, 48, 0.9), rgba(16, 16, 32, 0.95));
border: 1px solid rgba(255, 215, 0, 0.25);
border-radius: 20px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6), 0 0 30px rgba(255, 215, 0, 0.1);
}
.auth-lock-icon {
font-size: 3.8rem;
color: #ffd700;
text-shadow: 0 0 20px rgba(255, 215, 0, 0.4);
animation: pulse-glow 3s infinite ease-in-out;
}
@keyframes pulse-glow {
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 15px rgba(255, 215, 0, 0.4)); }
50% { transform: scale(1.05); filter: drop-shadow(0 0 25px rgba(255, 215, 0, 0.7)); }
}
/* ── Builder Header Toolbar ── */
.builder-header-bar {
border-color: rgba(108, 99, 255, 0.2);
transition: border-color 0.3s ease;
}
.builder-header-bar:focus-within {
border-color: rgba(108, 99, 255, 0.4);
}
.field-label {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--text-secondary, #94a3b8);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 0.35rem;
}
.input-glow {
background: rgba(14, 14, 28, 0.8) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
color: #f8fafc !important;
border-radius: 10px;
padding: 0.55rem 0.85rem;
transition: all 0.2s ease;
}
.input-glow:focus {
background: rgba(18, 18, 36, 0.95) !important;
border-color: var(--accent, #6c63ff) !important;
box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.25), 0 0 15px rgba(108, 99, 255, 0.2) !important;
}
.deck-name-input {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.deck-season-input {
font-size: 0.9rem;
font-weight: 600;
}
.deck-notes-input {
font-size: 0.85rem;
}
/* ── Badges & Status Indicators ── */
.status-pill-group {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.card-count {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 800;
padding: 0.5rem 0.9rem;
border-radius: 100px;
letter-spacing: 0.03em;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
white-space: nowrap;
}
.card-count.count-progress {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: #ffffff;
border: 1px solid rgba(167, 139, 250, 0.4);
box-shadow: 0 0 15px rgba(124, 58, 237, 0.3);
}
.card-count.count-complete {
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
color: #ffffff;
border: 1px solid rgba(52, 211, 153, 0.5);
box-shadow: 0 0 18px rgba(16, 185, 129, 0.4);
animation: pop-badge 0.4s ease;
}
.card-count.count-overflow {
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
color: #ffffff;
border: 1px solid rgba(248, 113, 113, 0.5);
box-shadow: 0 0 15px rgba(239, 68, 68, 0.4);
}
@keyframes pop-badge {
0% { transform: scale(0.9); }
50% { transform: scale(1.08); }
100% { transform: scale(1); }
}
.validity-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.82rem;
font-weight: 700;
padding: 0.5rem 0.9rem;
border-radius: 100px;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
white-space: nowrap;
}
.validity-badge.valid {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(5, 150, 105, 0.3));
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.4);
box-shadow: 0 0 12px rgba(16, 185, 129, 0.25);
}
.validity-badge.invalid {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(217, 119, 6, 0.25));
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.4);
box-shadow: 0 0 12px rgba(245, 158, 11, 0.2);
}
/* ── Action Buttons ── */
.save-btn {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
color: #ffffff !important;
border: 1px solid rgba(196, 181, 253, 0.3) !important;
border-radius: 10px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 0.6rem 1.4rem;
transition: all 0.25s ease;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
}
.save-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
transform: translateY(-2px);
box-shadow: 0 6px 22px rgba(99, 102, 241, 0.5), 0 0 15px rgba(139, 92, 246, 0.4);
border-color: rgba(255, 255, 255, 0.5) !important;
}
.save-btn:active:not(:disabled) {
transform: translateY(0);
}
.save-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.btn-ghost-secondary {
background: rgba(30, 30, 60, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #cbd5e1;
border-radius: 10px;
padding: 0.6rem 1.1rem;
font-weight: 600;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn-ghost-secondary:hover {
background: rgba(50, 50, 90, 0.8);
color: #ffffff;
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
}
/* ── Quick Stats & Analytics Bar ── */
.deck-stats-bar {
background: rgba(14, 14, 30, 0.7);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 0.75rem 1rem;
}
.stats-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.65rem;
border-radius: 8px;
font-size: 0.78rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.stats-chip.chip-agent {
color: #38bdf8;
border-color: rgba(56, 189, 248, 0.25);
background: rgba(56, 189, 248, 0.08);
}
.stats-chip.chip-spell {
color: #c084fc;
border-color: rgba(192, 132, 252, 0.25);
background: rgba(192, 132, 252, 0.08);
}
.stats-chip.chip-immortalized {
color: #fbbf24;
border-color: rgba(251, 191, 36, 0.25);
background: rgba(251, 191, 36, 0.08);
}
.syndicate-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 6px currentColor;
}
/* Syndicate Colors */
.syn-singularity { color: #f1f5f9; }
.syn-phasetide { color: #38bdf8; }
.syn-silence { color: #c084fc; }
.syn-lifeblood { color: #4ade80; }
.syn-sungrace { color: #fb923c; }
.syn-splintergleam { color: #f87171; }
/* ── Search & Filter Controls ── */
.filter-search-group .input-group-text {
background: rgba(14, 14, 28, 0.9);
border-color: rgba(255, 255, 255, 0.12);
color: #94a3b8;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
.filter-select {
background-color: rgba(14, 14, 28, 0.9) !important;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
color: #f1f5f9 !important;
border-radius: 10px;
font-size: 0.85rem;
font-weight: 500;
padding: 0.45rem 0.75rem;
transition: all 0.2s ease;
}
.filter-select:focus {
border-color: #6366f1 !important;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2) !important;
}
.btn-filter-clear {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #94a3b8;
border-radius: 8px;
font-size: 0.78rem;
padding: 0.35rem 0.75rem;
transition: all 0.2s ease;
}
.btn-filter-clear:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
/* ── Card Browser Grid ── */
.card-browser-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(145px, 1fr));
grid-auto-rows: max-content;
align-content: start;
align-items: start;
gap: 0.75rem;
max-height: 65vh;
padding-right: 0.25rem;
scrollbar-width: thin;
scrollbar-color: rgba(108, 99, 255, 0.3) rgba(14, 14, 28, 0.5);
}
.card-browser-grid::-webkit-scrollbar {
width: 6px;
}
.card-browser-grid::-webkit-scrollbar-thumb {
background: rgba(108, 99, 255, 0.3);
border-radius: 3px;
}
/* ── Browser Card ── */
.browser-card {
background: linear-gradient(180deg, rgba(26, 26, 52, 0.95) 0%, rgba(16, 16, 34, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.5rem;
position: relative;
user-select: none;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
height: 100%;
min-height: fit-content;
}
.browser-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: transparent;
transition: background 0.25s ease;
}
/* Category Accent lines */
.browser-card.card-agent::before { background: linear-gradient(90deg, #38bdf8, transparent); }
.browser-card.card-spell::before { background: linear-gradient(90deg, #c084fc, transparent); }
.browser-card.card-immortalized::before { background: linear-gradient(90deg, #fbbf24, transparent); }
.browser-card.cursor-pointer:hover {
transform: translateY(-5px) scale(1.02);
border-color: rgba(129, 140, 248, 0.6);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.5), 0 0 16px rgba(99, 102, 241, 0.3);
background: linear-gradient(180deg, rgba(34, 34, 68, 0.98) 0%, rgba(20, 20, 42, 1) 100%);
z-index: 5;
}
.browser-card.in-deck {
border-color: rgba(99, 102, 241, 0.5);
background: linear-gradient(180deg, rgba(30, 30, 65, 0.95) 0%, rgba(18, 18, 40, 0.98) 100%);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
}
.browser-card.max-copies {
opacity: 0.55;
filter: grayscale(25%);
cursor: not-allowed;
}
.browser-card.max-copies:hover {
transform: none;
box-shadow: none;
border-color: rgba(255, 255, 255, 0.1);
}
.card-img-wrapper {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #000;
aspect-ratio: 2.5 / 3.5;
width: 100%;
flex-shrink: 0;
margin-bottom: 0.4rem;
}
.card-img-wrapper img {
width: 100%;
height: 100%;
aspect-ratio: 2.5 / 3.5;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.browser-card:hover:not(.max-copies) .card-img-wrapper img {
transform: scale(1.05);
}
/* Mana Cost Gem */
.cost-gem {
position: absolute;
top: 6px;
left: 6px;
width: 26px;
height: 26px;
background: radial-gradient(circle at 30% 30%, #60a5fa, #1d4ed8);
border: 1.5px solid #bfdbfe;
border-radius: 50%;
color: #ffffff;
font-weight: 900;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 10px rgba(59, 130, 246, 0.5);
z-index: 3;
}
/* In-Deck Count Badge */
.deck-count-badge {
position: absolute;
top: 6px;
right: 6px;
padding: 0.15rem 0.45rem;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.02em;
z-index: 3;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
.deck-count-badge.count-1 {
background: rgba(56, 189, 248, 0.9);
color: #0f172a;
}
.deck-count-badge.count-2 {
background: rgba(251, 191, 36, 0.95);
color: #0f172a;
}
.deck-count-badge.count-3 {
background: linear-gradient(135deg, #10b981, #059669);
color: #ffffff;
border: 1px solid #6ee7b7;
}
/* Diver ribbon */
.diver-ribbon {
position: absolute;
bottom: 6px;
left: 6px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #0f172a;
font-weight: 900;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
border-radius: 4px;
border: 1px solid #fde68a;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
z-index: 3;
}
.card-title-text {
font-size: 0.82rem;
font-weight: 700;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
margin-bottom: 0.2rem;
}
.card-stats-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.72rem;
color: #94a3b8;
}
.card-cat-label {
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-cat-label.cat-agent { color: #38bdf8; }
.card-cat-label.cat-spell { color: #c084fc; }
.card-cat-label.cat-immortalized { color: #fbbf24; }
.combat-stats-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: 800;
font-size: 0.72rem;
background: rgba(0, 0, 0, 0.4);
padding: 0.1rem 0.35rem;
border-radius: 4px;
}
.atk-val { color: #f87171; }
.hp-val { color: #4ade80; }
/* ── Diver Slots ── */
.divers-section {
background: rgba(14, 14, 30, 0.7);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
padding: 0.85rem;
}
.diver-slot {
border-radius: 12px;
transition: all 0.25s ease;
min-height: 72px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
user-select: none;
cursor: pointer;
}
.diver-slot.empty-slot {
background: rgba(18, 18, 38, 0.6);
border: 2px dashed rgba(255, 215, 0, 0.3);
}
.diver-slot.empty-slot:hover {
background: rgba(26, 26, 52, 0.8);
border-color: rgba(255, 215, 0, 0.6);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.2);
transform: translateY(-2px);
}
.diver-slot.occupied-slot {
background: linear-gradient(135deg, rgba(255, 215, 0, 0.08) 0%, rgba(20, 20, 44, 0.95) 100%);
border: 1px solid rgba(255, 215, 0, 0.6);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 15px rgba(255, 215, 0, 0.15);
padding: 0.6rem 0.75rem;
}
.diver-slot.occupied-slot:hover {
border-color: #ffd700;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4), 0 0 20px rgba(255, 215, 0, 0.25);
}
.diver-badge-gold {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #0f172a;
font-weight: 800;
font-size: 0.68rem;
padding: 0.15rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.diver-remove-btn {
background: transparent;
border: none;
color: #f87171;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
line-height: 1;
}
.diver-remove-btn:hover {
color: #ef4444;
transform: scale(1.2);
filter: drop-shadow(0 0 6px rgba(239, 68, 68, 0.6));
}
/* ── Main Deck Cards List ── */
.deck-card-list {
max-height: 50vh;
padding-right: 0.35rem;
scrollbar-width: thin;
scrollbar-color: rgba(108, 99, 255, 0.3) rgba(14, 14, 28, 0.5);
}
.deck-card-list::-webkit-scrollbar {
width: 5px;
}
.deck-card-list::-webkit-scrollbar-thumb {
background: rgba(108, 99, 255, 0.3);
border-radius: 3px;
}
.deck-row-item {
background: rgba(18, 18, 38, 0.85);
border: 1px solid rgba(255, 255, 255, 0.07);
border-left: 3px solid #6366f1;
border-radius: 8px;
padding: 0.45rem 0.65rem;
margin-bottom: 0.35rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s ease;
}
.deck-row-item:hover {
background: rgba(28, 28, 56, 0.95);
border-color: rgba(255, 255, 255, 0.15);
border-left-color: #818cf8;
transform: translateX(2px);
}
.deck-row-item.row-agent { border-left-color: #38bdf8; }
.deck-row-item.row-spell { border-left-color: #c084fc; }
.deck-row-item.row-immortalized { border-left-color: #fbbf24; }
.mini-cost-pill {
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #3b82f6, #1e40af);
color: #ffffff;
font-size: 0.72rem;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.deck-row-title {
font-size: 0.85rem;
font-weight: 700;
color: #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quantity-stepper {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.stepper-btn {
width: 24px;
height: 24px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #e2e8f0;
font-weight: 800;
font-size: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.stepper-btn:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.3);
border-color: #818cf8;
color: #ffffff;
transform: scale(1.1);
}
.stepper-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.qty-pill {
min-width: 26px;
padding: 0.15rem 0.35rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 800;
text-align: center;
}
.qty-pill.qty-1 {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border: 1px solid rgba(165, 180, 252, 0.3);
}
.qty-pill.qty-2 {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.qty-pill.qty-3 {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.3);
}
/* Empty Deck State */
.empty-deck-box {
background: rgba(14, 14, 28, 0.5);
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 2.5rem 1rem;
text-align: center;
}
.empty-deck-icon {
font-size: 2.5rem;
color: #475569;
margin-bottom: 0.5rem;
}
/* ── Diver Picker Modal ── */
.modal-glass {
background: rgba(16, 16, 36, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 18px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.7), 0 0 30px rgba(255, 215, 0, 0.15);
}
.diver-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
grid-auto-rows: max-content;
align-content: start;
gap: 0.65rem;
max-height: 55vh;
padding-right: 0.25rem;
scrollbar-width: thin;
}
.diver-picker-card {
background: rgba(22, 22, 46, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.6rem;
transition: all 0.2s ease;
cursor: pointer;
}
.diver-picker-card:hover {
background: rgba(36, 36, 72, 0.95);
border-color: #ffd700;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4), 0 0 12px rgba(255, 215, 0, 0.25);
transform: translateY(-2px);
}
/* ── Save Toast Alert ── */
.save-toast {
background: linear-gradient(135deg, rgba(5, 150, 105, 0.95), rgba(16, 185, 129, 0.95)) !important;
backdrop-filter: blur(12px);
color: #ffffff !important;
border: 1px solid rgba(167, 243, 208, 0.6) !important;
border-radius: 12px;
padding: 0.75rem 1.25rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 20px rgba(16, 185, 129, 0.4);
animation: slide-in-toast 0.3s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1200;
}
@keyframes slide-in-toast {
from { transform: translateY(30px) scale(0.9); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
/* ── Responsive adjustments ── */
@media (max-width: 991px) {
.card-browser-grid {
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
max-height: 50vh;
}
}
+264
View File
@@ -0,0 +1,264 @@
@page "/my-decks"
@using Model
@using Web.Services
@inject UserDecksService UserDecksService
@inject AuthService AuthService
@inject NavigationManager Navigation
@inject NetworkStatusService NetworkStatus
@implements IDisposable
<PageTitle>Chrono CCG - My Decks</PageTitle>
<HeadContent>
<meta name="description" content="View and manage your saved Chrono CCG decks." />
</HeadContent>
<div class="my-decks-page container py-4">
@if (!AuthService.IsLoggedIn)
{
<div class="card bg-dark text-white border-secondary shadow p-5 text-center">
<i class="bi bi-shield-lock-fill text-warning mb-3" style="font-size: 3rem;"></i>
<h3 class="fw-bold mb-2">Authentication Required</h3>
<p class="text-secondary mb-4">Please log in to your Chrono CCG account to view and manage your saved decks.</p>
<div>
<a href="login" class="btn btn-warning fw-semibold px-4">
<i class="bi bi-box-arrow-in-right me-1"></i> Log In
</a>
</div>
</div>
}
else
{
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div>
<h1 class="h2 fw-bold mb-1 text-white">
<i class="bi bi-collection-play-fill text-warning me-2"></i> My Decks
</h1>
<p class="text-secondary mb-0">Manage and edit your custom PostgreSQL-persisted decks.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" @onclick="LoadDecksAsync" disabled="@isLoading">
<i class="bi bi-arrow-clockwise me-1"></i> Refresh
</button>
<a href="deck-builder" class="btn btn-primary btn-create-deck">
<i class="bi bi-plus-lg me-1"></i> Create Deck
</a>
</div>
</div>
@if (!string.IsNullOrEmpty(statusMessage))
{
<div class="alert @(statusIsError ? "alert-danger" : "alert-success") alert-dismissible fade show" role="alert">
<i class="bi @(statusIsError ? "bi-exclamation-triangle-fill" : "bi-check-circle-fill") me-2"></i>
@statusMessage
<button type="button" class="btn-close" @onclick="() => statusMessage = null"></button>
</div>
}
<div class="card bg-dark border-secondary p-3 mb-4">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap">
<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 bg-dark border-secondary text-white"
placeholder="Search decks by name, season, or notes..."
@bind="searchTerm" @bind:event="oninput" />
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="() => searchTerm = string.Empty"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @FilteredDecks.Count deck(s)
</div>
</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 your saved decks...</div>
</div>
}
else if (!FilteredDecks.Any())
{
<div class="card bg-dark border-secondary text-center py-5 text-secondary">
<i class="bi bi-journal-x fs-1 d-block mb-3 text-muted"></i>
<h4 class="text-white">No Decks Found</h4>
<p class="small mb-4">@(string.IsNullOrEmpty(searchTerm) ? "You haven't built any decks yet." : "No decks match your search criteria.")</p>
<div>
<a href="deck-builder" class="btn btn-primary">
<i class="bi bi-hammer me-1"></i> Open Deck Builder
</a>
</div>
</div>
}
else
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
@foreach (var deck in FilteredDecks)
{
<div class="col" @key="deck.Id">
<div class="card deck-card bg-dark border-secondary h-100 shadow-sm">
<div class="card-header border-secondary d-flex align-items-start justify-content-between gap-2">
<div>
<h5 class="card-title deck-name fw-bold text-white mb-1">@deck.Name</h5>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary">@(string.IsNullOrEmpty(deck.Season) ? "Season 1" : deck.Season)</span>
<span class="badge @(deck.IsValid ? "bg-success" : "bg-warning text-dark")">
@(deck.IsValid ? "Valid" : "Incomplete")
</span>
</div>
</div>
</div>
<div class="card-body">
<div class="deck-stats mb-3 d-flex gap-3 text-secondary small">
<div>
<i class="bi bi-stack text-primary me-1"></i>
<span class="text-white fw-semibold">@deck.Cards.Count</span>/40 cards
</div>
<div>
<i class="bi bi-shuffle text-warning me-1"></i>
<span class="text-white fw-semibold">@deck.Divers.Count</span>/2 divers
</div>
</div>
@if (deck.Divers.Count > 0)
{
<div class="mb-2">
<div class="text-secondary small fw-semibold mb-1">DIVERS</div>
<div class="d-flex flex-wrap gap-1">
@foreach (var diver in deck.Divers)
{
<span class="badge bg-dark border border-secondary text-info">@diver</span>
}
</div>
</div>
}
@if (!string.IsNullOrEmpty(deck.Notes))
{
<div class="deck-notes-preview bg-darker p-2 rounded text-secondary small mb-2 border border-secondary">
<div class="text-muted small fw-semibold">NOTES</div>
<div class="text-truncate">@deck.Notes</div>
</div>
}
<div class="text-muted small deck-updated-at mt-auto pt-2 border-top border-secondary">
<i class="bi bi-clock me-1"></i> Updated @deck.UpdatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")
</div>
</div>
<div class="card-footer border-secondary d-flex justify-content-between gap-2">
<a href="deck-builder/@deck.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil-square me-1"></i> Edit in Builder
</a>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteDeckAsync(deck.Id)" disabled="@isDeleting">
<i class="bi bi-trash me-1"></i> Delete
</button>
</div>
</div>
</div>
}
</div>
}
}
</div>
@code {
private List<UserDeck> decks = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isDeleting;
private string? statusMessage;
private bool statusIsError;
private List<UserDeck> FilteredDecks => string.IsNullOrWhiteSpace(searchTerm)
? decks
: decks.Where(d =>
(d.Name?.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ?? false) ||
(d.Season?.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ?? false) ||
(d.Notes?.Contains(searchTerm, StringComparison.OrdinalIgnoreCase) ?? false) ||
d.Cards.Any(c => c.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ||
d.Divers.Any(div => div.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
).ToList();
protected override async Task OnInitializedAsync()
{
AuthService.AuthStateChanged += OnAuthStateChanged;
await AuthService.InitializeAsync();
if (AuthService.IsLoggedIn)
{
await LoadDecksAsync();
}
else
{
isLoading = false;
}
}
private void OnAuthStateChanged()
{
_ = InvokeAsync(async () =>
{
if (AuthService.IsLoggedIn)
{
await LoadDecksAsync();
}
StateHasChanged();
});
}
private async Task LoadDecksAsync()
{
isLoading = true;
statusMessage = null;
try
{
decks = await UserDecksService.GetDecksAsync();
}
catch (Exception ex)
{
statusMessage = $"Failed to load decks: {ex.Message}";
statusIsError = true;
}
finally
{
isLoading = false;
}
}
private async Task DeleteDeckAsync(Guid id)
{
isDeleting = true;
statusMessage = null;
try
{
var success = await UserDecksService.DeleteDeckAsync(id);
if (success)
{
decks.RemoveAll(d => d.Id == id);
statusMessage = "Deck deleted successfully.";
statusIsError = false;
}
else
{
statusMessage = "Failed to delete deck.";
statusIsError = true;
}
}
catch (Exception ex)
{
statusMessage = $"Error deleting deck: {ex.Message}";
statusIsError = true;
}
finally
{
isDeleting = false;
}
}
public void Dispose()
{
AuthService.AuthStateChanged -= OnAuthStateChanged;
}
}
+1
View File
@@ -16,5 +16,6 @@ builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddScoped<NetworkStatusService>(); builder.Services.AddScoped<NetworkStatusService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CardNotesGraphQLService>(); builder.Services.AddScoped<CardNotesGraphQLService>();
builder.Services.AddScoped<UserDecksService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
+49 -5
View File
@@ -16,9 +16,9 @@ public class AuthService
_http = http; _http = http;
_networkStatus = networkStatus; _networkStatus = networkStatus;
_jsRuntime = serviceProvider.GetService(typeof(IJSRuntime)) as IJSRuntime; _jsRuntime = serviceProvider.GetService(typeof(IJSRuntime)) as IJSRuntime;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 5256 _graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 7266
? "/graphql" ? "/graphql"
: "http://localhost:5256/graphql"; : "https://localhost:7266/graphql";
} }
public bool IsLoggedIn => !string.IsNullOrEmpty(AuthToken); public bool IsLoggedIn => !string.IsNullOrEmpty(AuthToken);
@@ -34,6 +34,32 @@ public class AuthService
{ {
var storedToken = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "chrono_auth_token"); var storedToken = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "chrono_auth_token");
var storedUser = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "chrono_auth_user"); var storedUser = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "chrono_auth_user");
if (string.IsNullOrEmpty(storedToken) || string.IsNullOrEmpty(storedUser))
{
try
{
var cookieStr = await _jsRuntime.InvokeAsync<string?>("eval", "document.cookie");
if (!string.IsNullOrEmpty(cookieStr))
{
foreach (var part in cookieStr.Split(';'))
{
var kv = part.Trim().Split('=');
if (kv.Length == 2)
{
if (kv[0] == "chrono_auth_token" && string.IsNullOrEmpty(storedToken))
storedToken = kv[1];
if (kv[0] == "chrono_auth_user" && string.IsNullOrEmpty(storedUser))
storedUser = kv[1];
}
}
}
}
catch
{
}
}
if (!string.IsNullOrEmpty(storedToken) && !string.IsNullOrEmpty(storedUser)) if (!string.IsNullOrEmpty(storedToken) && !string.IsNullOrEmpty(storedUser))
{ {
AuthToken = storedToken; AuthToken = storedToken;
@@ -133,9 +159,12 @@ public class AuthService
if (httpResponse.IsSuccessStatusCode) if (httpResponse.IsSuccessStatusCode)
return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(); return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
if (_graphqlEndpoint == "/graphql") if (_graphqlEndpoint == "/graphql" || _graphqlEndpoint.StartsWith("https://"))
{ {
var fallbackResponse = await _http.PostAsJsonAsync("http://localhost:5256/graphql", request); var fallbackUrl = _graphqlEndpoint == "/graphql"
? "https://localhost:7266/graphql"
: "http://localhost:7266/graphql";
var fallbackResponse = await _http.PostAsJsonAsync(fallbackUrl, request);
if (fallbackResponse.IsSuccessStatusCode) if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(); return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
} }
@@ -144,12 +173,27 @@ public class AuthService
{ {
try try
{ {
var fallbackResponse = await _http.PostAsJsonAsync("http://localhost:5256/graphql", request); var fallbackUrl = _graphqlEndpoint.StartsWith("http://")
? "https://localhost:7266/graphql"
: "http://localhost:7266/graphql";
var fallbackResponse = await _http.PostAsJsonAsync(fallbackUrl, request);
if (fallbackResponse.IsSuccessStatusCode) if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(); return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
} }
catch catch
{ {
if (_graphqlEndpoint == "/graphql")
{
try
{
var fallbackHttp = await _http.PostAsJsonAsync("http://localhost:7266/graphql", request);
if (fallbackHttp.IsSuccessStatusCode)
return await fallbackHttp.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
catch
{
}
}
} }
} }
+23 -5
View File
@@ -17,9 +17,9 @@ public class CardNotesGraphQLService
_http = http; _http = http;
_networkStatus = networkStatus; _networkStatus = networkStatus;
_authService = authService; _authService = authService;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 5256 _graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 7266
? "/graphql" ? "/graphql"
: "http://localhost:5256/graphql"; : "https://localhost:7266/graphql";
} }
public async Task<bool> IsApiOnlineAsync() public async Task<bool> IsApiOnlineAsync()
@@ -123,9 +123,12 @@ public class CardNotesGraphQLService
if (httpResponse.IsSuccessStatusCode) if (httpResponse.IsSuccessStatusCode)
return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(); return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
if (_graphqlEndpoint == "/graphql") if (_graphqlEndpoint == "/graphql" || _graphqlEndpoint.StartsWith("https://"))
{ {
var fallbackResponse = await SendToUrlAsync("http://localhost:5256/graphql"); var fallbackUrl = _graphqlEndpoint == "/graphql"
? "https://localhost:7266/graphql"
: "http://localhost:7266/graphql";
var fallbackResponse = await SendToUrlAsync(fallbackUrl);
if (fallbackResponse.IsSuccessStatusCode) if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(); return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
} }
@@ -134,12 +137,27 @@ public class CardNotesGraphQLService
{ {
try try
{ {
var fallbackResponse = await SendToUrlAsync("http://localhost:5256/graphql"); var fallbackUrl = _graphqlEndpoint.StartsWith("http://")
? "https://localhost:7266/graphql"
: "http://localhost:7266/graphql";
var fallbackResponse = await SendToUrlAsync(fallbackUrl);
if (fallbackResponse.IsSuccessStatusCode) if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(); return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
} }
catch catch
{ {
if (_graphqlEndpoint == "/graphql")
{
try
{
var fallbackHttp = await SendToUrlAsync("http://localhost:7266/graphql");
if (fallbackHttp.IsSuccessStatusCode)
return await fallbackHttp.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
catch
{
}
}
} }
} }
+352
View File
@@ -0,0 +1,352 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Model;
namespace Web.Services;
public class UserDecksService
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly HttpClient _http;
private readonly AuthService _authService;
private readonly NetworkStatusService _networkStatus;
private readonly string _graphqlEndpoint;
private readonly string _restEndpoint;
public UserDecksService(HttpClient http, AuthService authService, NetworkStatusService networkStatus)
{
_http = http;
_authService = authService;
_networkStatus = networkStatus;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 7266
? "/graphql"
: "https://localhost:7266/graphql";
_restEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 7266
? "/api/decks"
: "https://localhost:7266/api/decks";
}
public async Task<List<UserDeck>> GetDecksAsync()
{
if (!_networkStatus.IsOnline)
return [];
try
{
var request = new GraphQLRequest
{
Query = @"query GetDecks {
decks {
id
name
cards
divers
notes
season
updatedAt
}
}"
};
var response = await SendGraphQLAsync<GetDecksResponseData>(request);
if (response?.Data?.Decks != null)
return response.Data.Decks;
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] GraphQL GetDecks failed: {ex.Message}. Trying REST.");
}
// Fallback to REST
try
{
var req = new HttpRequestMessage(HttpMethod.Get, _restEndpoint);
AddAuthHeader(req);
var res = await _http.SendAsync(req);
if (res.IsSuccessStatusCode)
{
var decks = await res.Content.ReadFromJsonAsync<List<UserDeck>>(JsonOptions);
return decks ?? [];
}
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] REST GetDecks failed: {ex.Message}");
}
return [];
}
public async Task<UserDeck?> GetDeckAsync(Guid id)
{
if (!_networkStatus.IsOnline)
return null;
try
{
var request = new GraphQLRequest
{
Query = @"query GetDeck($id: UUID!) {
deck(id: $id) {
id
name
cards
divers
notes
season
updatedAt
}
}",
Variables = new Dictionary<string, object> { ["id"] = id }
};
var response = await SendGraphQLAsync<GetDeckResponseData>(request);
if (response?.Data?.Deck != null)
return response.Data.Deck;
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] GraphQL GetDeck failed: {ex.Message}. Trying REST.");
}
// Fallback to REST
try
{
var req = new HttpRequestMessage(HttpMethod.Get, $"{_restEndpoint}/{id}");
AddAuthHeader(req);
var res = await _http.SendAsync(req);
if (res.IsSuccessStatusCode)
{
return await res.Content.ReadFromJsonAsync<UserDeck>(JsonOptions);
}
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] REST GetDeck failed: {ex.Message}");
}
return null;
}
public async Task<UserDeck?> SaveDeckAsync(UserDeck deck)
{
if (!_networkStatus.IsOnline)
return null;
try
{
var request = new GraphQLRequest
{
Query = @"mutation SaveDeck($input: UserDeckInput!) {
saveDeck(input: $input) {
id
name
cards
divers
notes
season
updatedAt
}
}",
Variables = new Dictionary<string, object>
{
["input"] = new Dictionary<string, object?>
{
["id"] = deck.Id,
["name"] = deck.Name,
["cards"] = deck.Cards,
["divers"] = deck.Divers,
["notes"] = deck.Notes,
["season"] = deck.Season
}
}
};
var response = await SendGraphQLAsync<SaveDeckResponseData>(request);
if (response?.Data?.SaveDeck != null)
return response.Data.SaveDeck;
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] GraphQL SaveDeck failed: {ex.Message}. Trying REST.");
}
// Fallback to REST
try
{
var req = new HttpRequestMessage(HttpMethod.Post, _restEndpoint)
{
Content = JsonContent.Create(deck)
};
AddAuthHeader(req);
var res = await _http.SendAsync(req);
if (res.IsSuccessStatusCode)
{
return await res.Content.ReadFromJsonAsync<UserDeck>(JsonOptions);
}
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] REST SaveDeck failed: {ex.Message}");
}
return null;
}
public async Task<bool> DeleteDeckAsync(Guid id)
{
if (!_networkStatus.IsOnline)
return false;
try
{
var request = new GraphQLRequest
{
Query = @"mutation DeleteDeck($id: UUID!) {
deleteDeck(id: $id)
}",
Variables = new Dictionary<string, object> { ["id"] = id }
};
var response = await SendGraphQLAsync<DeleteDeckResponseData>(request);
if (response?.Data?.DeleteDeck == true)
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] GraphQL DeleteDeck failed: {ex.Message}. Trying REST.");
}
// Fallback to REST
try
{
var req = new HttpRequestMessage(HttpMethod.Delete, $"{_restEndpoint}/{id}");
AddAuthHeader(req);
var res = await _http.SendAsync(req);
return res.IsSuccessStatusCode;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] REST DeleteDeck failed: {ex.Message}");
}
return false;
}
private void AddAuthHeader(HttpRequestMessage request)
{
if (!string.IsNullOrEmpty(_authService.AuthToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authService.AuthToken);
}
}
private async Task<GraphQLResponse<T>?> SendGraphQLAsync<T>(GraphQLRequest request)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Post, _graphqlEndpoint)
{
Content = JsonContent.Create(request)
};
AddAuthHeader(httpRequest);
try
{
var httpResponse = await _http.SendAsync(httpRequest);
if (httpResponse.IsSuccessStatusCode)
return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>(JsonOptions);
if (_graphqlEndpoint == "/graphql" || _graphqlEndpoint.StartsWith("https://"))
{
var fallbackUrl = _graphqlEndpoint == "/graphql"
? "https://localhost:7266/graphql"
: "http://localhost:7266/graphql";
var fallbackReq = new HttpRequestMessage(HttpMethod.Post, fallbackUrl)
{
Content = JsonContent.Create(request)
};
AddAuthHeader(fallbackReq);
var fallbackRes = await _http.SendAsync(fallbackReq);
if (fallbackRes.IsSuccessStatusCode)
return await fallbackRes.Content.ReadFromJsonAsync<GraphQLResponse<T>>(JsonOptions);
}
}
catch
{
try
{
var fallbackUrl = _graphqlEndpoint.StartsWith("http://")
? "https://localhost:7266/graphql"
: "http://localhost:7266/graphql";
var fallbackReq = new HttpRequestMessage(HttpMethod.Post, fallbackUrl)
{
Content = JsonContent.Create(request)
};
AddAuthHeader(fallbackReq);
var fallbackRes = await _http.SendAsync(fallbackReq);
if (fallbackRes.IsSuccessStatusCode)
return await fallbackRes.Content.ReadFromJsonAsync<GraphQLResponse<T>>(JsonOptions);
}
catch
{
if (_graphqlEndpoint == "/graphql")
{
try
{
var fallbackReq = new HttpRequestMessage(HttpMethod.Post, "http://localhost:7266/graphql")
{
Content = JsonContent.Create(request)
};
AddAuthHeader(fallbackReq);
var fallbackRes = await _http.SendAsync(fallbackReq);
if (fallbackRes.IsSuccessStatusCode)
return await fallbackRes.Content.ReadFromJsonAsync<GraphQLResponse<T>>(JsonOptions);
}
catch
{
}
}
}
}
return null;
}
private class GraphQLRequest
{
[JsonPropertyName("query")] public string Query { get; set; } = string.Empty;
[JsonPropertyName("variables")] public Dictionary<string, object>? Variables { get; set; }
}
private class GraphQLResponse<T>
{
[JsonPropertyName("data")] public T? Data { get; set; }
[JsonPropertyName("errors")] public List<GraphQLError>? Errors { get; set; }
}
private class GraphQLError
{
[JsonPropertyName("message")] public string Message { get; set; } = string.Empty;
}
private class GetDecksResponseData
{
[JsonPropertyName("decks")] public List<UserDeck>? Decks { get; set; }
}
private class GetDeckResponseData
{
[JsonPropertyName("deck")] public UserDeck? Deck { get; set; }
}
private class SaveDeckResponseData
{
[JsonPropertyName("saveDeck")] public UserDeck? SaveDeck { get; set; }
}
private class DeleteDeckResponseData
{
[JsonPropertyName("deleteDeck")] public bool DeleteDeck { get; set; }
}
}
+3 -1
View File
@@ -41,7 +41,9 @@
"type": "file-explorer", "type": "file-explorer",
"state": { "state": {
"sortOrder": "alphabetical", "sortOrder": "alphabetical",
"autoReveal": false "autoReveal": false,
"showSearch": false,
"searchQuery": ""
}, },
"icon": "lucide-folder-closed", "icon": "lucide-folder-closed",
"title": "Files" "title": "Files"
+6 -2
View File
@@ -1,4 +1,4 @@
Create a fully functional deck builder in the Server project. Create a fully functional deck builder in the Web project.
A deck can have up to 3 of a card, for a total of 40 cards. A deck can have up to 3 of a card, for a total of 40 cards.
@@ -6,7 +6,11 @@ For example 13, 3 copies of a card, and 1, 1 copy of a card for a total of 40.
A deck also has 2 diver cards. The diver cards must be unique, and cannot be a card from the deck. A deck also has 2 diver cards. The diver cards must be unique, and cannot be a card from the deck.
This deck data will be saved to postgreSQL.. This deck data will be saved to postgreSQL.
Use the API project to handle the calls to the db for saving and loading the deck.
Only show the deckbuilder if the user is logged in.
You can also add additional notes on a deck. You can also add additional notes on a deck.