...
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user