472 lines
17 KiB
Plaintext
472 lines
17 KiB
Plaintext
@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;
|
||
}
|
||
}
|
||
|
||
}
|