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