Restructuing

This commit is contained in:
2026-07-05 16:30:58 -04:00
parent 678e20d402
commit 7b0f2ce1a5
767 changed files with 354 additions and 76 deletions
@@ -0,0 +1,452 @@
@page "/deck-builder"
@page "/deck-builder/{Id:guid}"
@using Model
@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) =>
_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;
}
}
}