Files
ChronoCCG/Chrono/Cloud/Components/Pages/DeckBuilder.razor
T

472 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/deck-builder"
@page "/deck-builder/{Id:guid}"
@rendermode InteractiveServer
@inject NavigationManager Nav
@inject AppDbContext Db
<PageTitle>@(Id == null ? "New Deck" : "Edit Deck") - Chrono CCG</PageTitle>
<div class="builder-topbar">
<a href="/my-decks" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> My Decks
</a>
<input @bind="deckName" @bind:event="oninput" class="form-control deck-name-input" placeholder="Deck name..."
maxlength="80"/>
<input @bind="deckSeason" @bind:event="oninput" class="form-control season-input" placeholder="Season (e.g. S1)"
maxlength="20"/>
<span class="validity-badge @(IsValid ? "badge-valid" : "badge-invalid")">
<i class="bi @(IsValid ? "bi-check-circle-fill" : "bi-exclamation-circle-fill")"></i>
@ValidationMessage
</span>
<button class="btn btn-primary save-btn" @onclick="SaveDeck" disabled="@isSaving">
@if (isSaving)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else
{
<i class="bi bi-floppy-fill me-1"></i>
}
Save
</button>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="save-toast @(saveError ? "save-error" : "save-ok")">@saveMessage</span>
}
</div>
<div class="builder-layout">
<!-- LEFT: Card Browser -->
<div class="browser-panel">
<div class="category-tabs">
<button class="tab @(categoryFilter == "" ? "active" : "")" @onclick='() => categoryFilter = ""'>
<i class="bi bi-grid-3x3-gap-fill"></i> All
</button>
<button class="tab agent @(categoryFilter == "Agent" ? "active" : "")"
@onclick='() => categoryFilter = "Agent"'>
<i class="bi bi-person-fill"></i> Agents
</button>
<button class="tab agent @(categoryFilter == "Immortalized" ? "active" : "")"
@onclick='() => categoryFilter = "Immortalized"'>
<i class="bi bi-star-fill"></i> Immortalized
</button>
<button class="tab spell @(categoryFilter == "Spell" ? "active" : "")"
@onclick='() => categoryFilter = "Spell"'>
<i class="bi bi-wand"></i> Spells
</button>
</div>
<div class="filter-bar">
<div class="search-wrapper">
<i class="bi bi-search search-icon"></i>
<input @bind="search" @bind:event="oninput" class="form-control search-input"
placeholder="Search cards..."/>
@if (search.Length > 0)
{
<button class="search-clear" @onclick='() => search = ""'><i class="bi bi-x-lg"></i></button>
}
</div>
<select @bind="factionFilter" class="form-select filter-select">
<option value="">All Factions</option>
@foreach (var f in factions)
{
<option value="@f">@f</option>
}
</select>
<div class="col-picker" title="Cards per row">
@for (var c = 1; c <= 6; c++)
{
var cols = c;
<button class="col-btn @(gridColumns == cols ? "active" : "")"
@onclick="() => gridColumns = cols">@cols</button>
}
</div>
</div>
<div class="browser-grid" style="--grid-cols: @gridColumns">
@foreach (var card in BrowserCards)
{
var count = CountInDeck(card.Name);
var maxed = count >= 3;
<div @key="card.Name" class="browser-card @(count > 0 ? "in-deck" : "") @(maxed ? "maxed" : "")"
@onclick="() => AddCard(card)"
title="@(count > 0 ? $"{card.Name} ({count}/3)" : card.Name)">
<div class="browser-card-img">
<img src="@card.ImagePath" alt="@card.Name" loading="lazy"
onerror="this.style.display='none'"/>
@if (count > 0)
{
<span class="in-deck-count">@count</span>
}
@if (maxed)
{
<div class="maxed-overlay"><i class="bi bi-lock-fill"></i></div>
}
</div>
<div class="browser-card-name">@card.Name</div>
</div>
}
</div>
</div>
<!-- RIGHT: Deck Panel -->
<div class="deck-panel">
<div class="deck-status-bar">
<span
class="card-count @(deckCards.Count == 40 ? "count-ok" : deckCards.Count > 40 ? "count-over" : "count-under")">
<i class="bi bi-stack"></i> @deckCards.Count<span class="count-denom">/40</span>
</span>
<button class="btn btn-sm btn-outline-danger ms-auto" @onclick="ClearDeck" title="Clear deck">
<i class="bi bi-trash3"></i> Clear
</button>
</div>
@if (deckCards.Count > 0)
{
<div class="mana-curve">
@for (var cost = 0; cost <= 10; cost++)
{
var c = cost;
var costCount = ManaCurveData.TryGetValue(c, out var v) ? v : 0;
var maxVal = ManaCurveData.Values.DefaultIfEmpty(1).Max();
var heightPct = maxVal > 0 ? costCount * 100 / maxVal : 0;
<div class="curve-col">
<div class="curve-bar-wrap">
<span class="curve-count">@(costCount > 0 ? costCount.ToString() : "")</span>
<div class="curve-bar" style="height: @heightPct%"></div>
</div>
<div class="curve-label">@(c == 10 ? "10+" : c.ToString())</div>
</div>
}
</div>
}
<div class="deck-card-list">
@if (DeckGroups.Count == 0)
{
<div class="deck-empty">
<i class="bi bi-collection"></i>
<span>Click cards on the left to add them</span>
</div>
}
@foreach (var group in DeckGroups)
{
var cardData = group.Card;
<div class="deck-row">
<img src="@cardData.ImagePath" alt="@cardData.Name" class="deck-row-img" loading="lazy"
onerror="this.style.display='none'"/>
<span class="deck-row-name">@cardData.Name</span>
@if (cardData.Cost.HasValue)
{
<span class="deck-row-cost"><i class="bi bi-lightning-fill"></i>@cardData.Cost</span>
}
<div class="deck-row-controls">
<button class="count-btn" @onclick="() => RemoveCard(cardData.Name)"></button>
<span class="count-display">@group.Count</span>
<button class="count-btn" @onclick="() => AddCard(cardData)" disabled="@(group.Count >= 3)">+
</button>
</div>
</div>
}
</div>
<div class="divers-section">
<div class="section-header">
<i class="bi bi-person-arms-up"></i> Divers
<span class="section-count">@divers.Count(d => d != null)/2</span>
</div>
@for (var i = 0; i < 2; i++)
{
var idx = i;
var diver = idx < divers.Count ? divers[idx] : null;
<div class="diver-slot @(diver != null ? "filled" : "empty")" @onclick="() => OpenDiverPicker(idx)">
@if (diver != null)
{
var diverCard = AllCards.FirstOrDefault(c => c.Name == diver);
@if (diverCard != null)
{
<img src="@diverCard.ImagePath" class="diver-thumb" alt="@diver"
onerror="this.style.display='none'"/>
}
<span class="diver-name">@diver</span>
<button class="diver-remove" @onclick:stopPropagation="true" @onclick="() => RemoveDiver(idx)">
<i class="bi bi-x"></i>
</button>
}
else
{
<i class="bi bi-plus-circle"></i>
<span>Select Diver @(idx + 1)</span>
}
</div>
}
</div>
<div class="notes-section">
<div class="section-header"><i class="bi bi-pencil-fill"></i> Notes</div>
<textarea @bind="deckNotes" class="form-control notes-input" rows="4"
placeholder="Strategy notes, matchup tips, card choices..."></textarea>
</div>
</div>
</div>
@if (showDiverPicker)
{
<div class="modal-backdrop" @onclick="CloseDiverPicker"></div>
<div class="diver-modal">
<div class="diver-modal-header">
<h5><i class="bi bi-person-arms-up"></i> Select Diver @(activeDiverSlot + 1)</h5>
<button class="btn-close" @onclick="CloseDiverPicker"></button>
</div>
<div class="filter-bar mb-2">
<div class="search-wrapper">
<i class="bi bi-search search-icon"></i>
<input @bind="diverSearch" @bind:event="oninput" class="form-control search-input"
placeholder="Search..."/>
</div>
</div>
<div class="diver-picker-grid">
@foreach (var card in DiverPickerCards)
{
<div class="diver-picker-card" @onclick="() => SelectDiver(card.Name)">
<img src="@card.ImagePath" alt="@card.Name" loading="lazy" onerror="this.style.display='none'"/>
<span>@card.Name</span>
</div>
}
</div>
</div>
}
@code {
[Parameter] public Guid? Id { get; set; }
private List<CardData> AllCards => CardDatabase.Cards;
private List<string> factions = [];
private string deckName = "";
private string deckSeason = "";
private string deckNotes = "";
private List<string> deckCards = [];
private List<string> divers = [];
private string search = "";
private string categoryFilter = "";
private string factionFilter = "";
private int gridColumns = 4;
private bool showDiverPicker;
private int activeDiverSlot;
private string diverSearch = "";
private bool isSaving;
private string saveMessage = "";
private bool saveError;
private bool IsValid => deckCards.Count == 40 && divers.Count == 2 && divers.All(d => d != null);
private string ValidationMessage => (deckCards.Count, divers.Count) switch
{
(< 40, _) => $"{40 - deckCards.Count} cards short",
(> 40, _) => $"{deckCards.Count - 40} cards over",
(40, < 2) => "Need 2 divers",
_ => "Valid"
};
private IEnumerable<CardData> BrowserCards => AllCards
.Where(c => c.Category is "Agent" or "Spell" or "Immortalized")
.Where(c => categoryFilter == "" || c.Category == categoryFilter)
.Where(c => factionFilter == "" || c.Faction == factionFilter)
.Where(c => search == "" || c.MatchesSearch(search));
private record DeckGroup(CardData Card, int Count);
private List<DeckGroup> DeckGroups => deckCards
.GroupBy(name => name)
.Select(g => new DeckGroup(AllCards.First(c => c.Name == g.Key), g.Count()))
.OrderBy(g => g.Card.Cost ?? 99)
.ThenBy(g => g.Card.Name)
.ToList();
private IEnumerable<CardData> DiverPickerCards
{
get
{
var inDeck = deckCards.ToHashSet();
var otherDiverSlot = activeDiverSlot == 0
? divers.Count > 1 ? divers[1] : null
: divers.Count > 0 ? divers[0] : null;
return AllCards
.Where(c => c.Category is "Agent" or "Spell" or "Immortalized")
.Where(c => !inDeck.Contains(c.Name))
.Where(c => c.Name != otherDiverSlot)
.Where(c => diverSearch == "" || c.MatchesSearch(diverSearch));
}
}
private Dictionary<int, int> ManaCurveData
{
get
{
var result = new Dictionary<int, int>();
foreach (var name in deckCards)
{
var card = AllCards.FirstOrDefault(c => c.Name == name);
if (card == null) continue;
var bucket = Math.Min(card.Cost ?? 0, 10);
result[bucket] = result.GetValueOrDefault(bucket) + 1;
}
return result;
}
}
private Dictionary<string, int> _deckCounts = new();
private void RebuildDeckCounts()
{
_deckCounts = deckCards.GroupBy(n => n).ToDictionary(g => g.Key, g => g.Count());
}
private int CountInDeck(string name)
{
return _deckCounts.TryGetValue(name, out var c) ? c : 0;
}
protected override async Task OnParametersSetAsync()
{
factions = AllCards
.Where(c => c.Faction != null)
.Select(c => c.Faction!)
.Distinct()
.OrderBy(f => f)
.ToList();
if (Id.HasValue)
await LoadDeck(Id.Value);
}
private async Task LoadDeck(Guid id)
{
var deck = await Db.UserDecks.FindAsync(id);
if (deck == null) return;
deckName = deck.Name;
deckSeason = deck.Season ?? "";
deckNotes = deck.Notes ?? "";
deckCards = new List<string>(deck.Cards);
divers = new List<string>(deck.Divers);
RebuildDeckCounts();
}
private void AddCard(CardData card)
{
if (CountInDeck(card.Name) >= 3) return;
deckCards.Add(card.Name);
RebuildDeckCounts();
}
private void RemoveCard(string name)
{
var idx = deckCards.LastIndexOf(name);
if (idx >= 0) deckCards.RemoveAt(idx);
RebuildDeckCounts();
}
private void ClearDeck()
{
deckCards.Clear();
RebuildDeckCounts();
}
private void OpenDiverPicker(int slot)
{
activeDiverSlot = slot;
diverSearch = "";
showDiverPicker = true;
}
private void CloseDiverPicker()
{
showDiverPicker = false;
}
private void SelectDiver(string name)
{
while (divers.Count <= activeDiverSlot)
divers.Add(null!);
divers[activeDiverSlot] = name;
showDiverPicker = false;
}
private void RemoveDiver(int slot)
{
if (slot < divers.Count)
divers.RemoveAt(slot);
}
private async Task SaveDeck()
{
if (string.IsNullOrWhiteSpace(deckName))
{
saveMessage = "Please enter a deck name.";
saveError = true;
return;
}
isSaving = true;
saveMessage = "";
try
{
if (Id.HasValue)
{
var existing = await Db.UserDecks.FindAsync(Id.Value);
if (existing != null)
{
existing.Name = deckName;
existing.Cards = new List<string>(deckCards);
existing.Divers = new List<string>(divers);
existing.Notes = deckNotes;
existing.Season = deckSeason;
existing.UpdatedAt = DateTime.UtcNow;
await Db.SaveChangesAsync();
saveMessage = "Saved!";
saveError = false;
}
}
else
{
var deck = new UserDeck
{
Name = deckName,
Cards = new List<string>(deckCards),
Divers = new List<string>(divers),
Notes = deckNotes,
Season = deckSeason,
UpdatedAt = DateTime.UtcNow
};
Db.UserDecks.Add(deck);
await Db.SaveChangesAsync();
saveMessage = "Saved!";
saveError = false;
Nav.NavigateTo($"/deck-builder/{deck.Id}", replace: true);
}
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
saveError = true;
}
finally
{
isSaving = false;
}
}
}