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 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");
// 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();
+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-icons@1.11.3/font/bootstrap-icons.min.css"/>
<link rel="stylesheet" href="app.css"/>
<HeadOutlet/>
<HeadOutlet @rendermode="InteractiveServer"/>
</head>
<body>
<Routes/>
<Routes @rendermode="InteractiveServer"/>
<script src="_framework/blazor.web.js"></script>
</body>
@@ -117,69 +117,63 @@
@if (showEditModal)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content chrono-modal">
<div class="modal-header border-secondary border-opacity-25">
<h5 class="modal-title text-white">
<i class="bi @(isNewUser ? "bi-person-plus-fill" : "bi-pencil-square") me-2 text-primary"></i>
@(isNewUser ? "Add New User" : $"Edit User: {currentUser.Username}")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom">
<div class="modal-header-custom">
<h5 class="modal-title-custom">
<i class="bi @(isNewUser ? "bi-person-plus-fill" : "bi-pencil-square") me-2 text-primary"></i>
@(isNewUser ? "Add New User" : $"Edit User: {currentUser.Username}")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-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 class="modal-body">
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">USERNAME</label>
<input type="text" class="form-control form-control-chrono"
@bind="currentUser.Username" disabled="@(!isNewUser)"
placeholder="e.g. player1"/>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">PASSWORD</label>
<input type="password" class="form-control form-control-chrono"
@bind="currentUser.Password"
placeholder="Enter password..."/>
</div>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveUserAsync" disabled="@isSaving">
<i class="bi bi-save me-1"></i> @(isSaving ? "Saving..." : "Save User")
</button>
<div 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-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>
}
@if (showDeleteModal && userToDelete != null)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content chrono-modal">
<div class="modal-header border-secondary border-opacity-25">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i> Confirm Delete
</h5>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body">
<p class="text-light mb-0">
Are you sure you want to delete user <strong class="text-white">@userToDelete.Username</strong>?
</p>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">
Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync"
disabled="@isSaving">
<i class="bi bi-trash3-fill me-1"></i> @(isSaving ? "Deleting..." : "Delete")
</button>
</div>
<div class="modal-backdrop-custom">
<div class="modal-dialog-custom" style="max-width: 450px;">
<div class="modal-header-custom">
<h5 class="modal-title-custom text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i> Confirm Delete
</h5>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-light mb-0">
Are you sure you want to delete user <strong class="text-white">@userToDelete.Username</strong>?
</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">
Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync"
disabled="@isSaving">
<i class="bi bi-trash3-fill me-1"></i> @(isSaving ? "Deleting..." : "Delete")
</button>
</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);
modelBuilder.Entity("Chrono.Model.CardNote", b =>
modelBuilder.Entity("Model.CardNote", b =>
{
b.Property<string>("Username")
.HasColumnType("text");
b.Property<string>("CardName")
.HasColumnType("text");
@@ -30,12 +33,32 @@ namespace Cloud.Migrations
.IsRequired()
.HasColumnType("text");
b.HasKey("CardName");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Username", "CardName");
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")
.ValueGeneratedOnAdd()
@@ -69,7 +92,7 @@ namespace Cloud.Migrations
b.ToTable("PasskeyCredentials");
});
modelBuilder.Entity("Cloud.Models.UserDeck", b =>
modelBuilder.Entity("Model.UserDeck", b =>
{
b.Property<Guid>("Id")
.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.Models;
using DatabaseTest.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -12,6 +13,25 @@ builder.Services.AddScoped<IDatabaseService, DatabaseService>();
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.
if (!app.Environment.IsDevelopment())
{
@@ -404,6 +404,9 @@ public class DatabaseService : IDatabaseService
public async Task<OperationResult> SaveUserAsync(User user, bool isNew)
{
user.Username = user.Username?.Trim() ?? "";
user.Password = user.Password?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(user.Username))
return OperationResult.Fail("Username is required.");
if (string.IsNullOrWhiteSpace(user.Password))
@@ -487,11 +490,13 @@ public class DatabaseService : IDatabaseService
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))
return OperationResult.Fail("Card name is required.");
note.Username ??= "";
try
{
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>
<div class="decks-header-row">
<p class="text-secondary">@decks.Count deck@(decks.Count != 1 ? "s" : "")</p>
<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 class="d-flex align-items-center gap-3">
<a href="deck-builder" class="btn btn-primary btn-sm btn-create-deck">
<i class="bi bi-plus-lg me-1"></i> Create Deck
</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>
+127
View File
@@ -1,6 +1,8 @@
using API.Services;
using Cloud.Models;
using DatabaseTest.Models;
using DatabaseTest.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Model;
@@ -283,4 +285,129 @@ public class DatabaseServiceTests
Assert.That(description, Does.Contain("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);
}
[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]
public void AppDbContext_Model_ConfiguresRequiredEntities()
{
@@ -280,7 +309,7 @@ public class GraphQLTests
var usersEntity = _db.Model.FindEntityType(typeof(User));
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);
}
@@ -38,6 +38,19 @@
<i class="bi bi-journal-text nav-icon"></i> Decks
</NavLink>
</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">
<NavLink class="nav-link" href="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<AuthService>();
builder.Services.AddScoped<CardNotesGraphQLService>();
builder.Services.AddScoped<UserDecksService>();
await builder.Build().RunAsync();
+49 -5
View File
@@ -16,9 +16,9 @@ public class AuthService
_http = http;
_networkStatus = networkStatus;
_jsRuntime = serviceProvider.GetService(typeof(IJSRuntime)) as IJSRuntime;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 5256
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 7266
? "/graphql"
: "http://localhost:5256/graphql";
: "https://localhost:7266/graphql";
}
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 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))
{
AuthToken = storedToken;
@@ -133,9 +159,12 @@ public class AuthService
if (httpResponse.IsSuccessStatusCode)
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)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
@@ -144,12 +173,27 @@ public class AuthService
{
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)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
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;
_networkStatus = networkStatus;
_authService = authService;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 5256
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 7266
? "/graphql"
: "http://localhost:5256/graphql";
: "https://localhost:7266/graphql";
}
public async Task<bool> IsApiOnlineAsync()
@@ -123,9 +123,12 @@ public class CardNotesGraphQLService
if (httpResponse.IsSuccessStatusCode)
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)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
@@ -134,12 +137,27 @@ public class CardNotesGraphQLService
{
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)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
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; }
}
}