Restructuing

This commit is contained in:
2026-07-05 16:30:58 -04:00
parent 678e20d402
commit 7b0f2ce1a5
767 changed files with 354 additions and 76 deletions
+346
View File
@@ -0,0 +1,346 @@
@page "/cards"
@using Model
@inject AnalyticsService AnalyticsService
<PageTitle>Chrono CCG - Card Gallery</PageTitle>
<HeadContent>
<meta name="description" content="Browse and filter all Chrono CCG cards. Explore agents, spells, and more."/>
</HeadContent>
<div class="gallery-container">
<div class="gallery-header">
<h1>Card Gallery</h1>
<p class="text-secondary">Browse all @allCards.Count cards</p>
</div>
<div class="category-tabs">
<button class="tab @(categoryFilter == "" ? "active" : "")" @onclick='@(() => SetCategory(""))'>
<i class="bi bi-grid-3x3-gap-fill"></i> All
</button>
<button class="tab agent @(categoryFilter == "Agent" ? "active" : "")" @onclick='@(() => SetCategory("Agent"))'>
<i class="bi bi-person-fill"></i> Agents
</button>
<button class="tab agent @(categoryFilter == "Immortalized" ? "active" : "")"
@onclick='@(() => SetCategory("Immortalized"))'>
<i class="bi bi-person-fill"></i> Immortalized
</button>
<div class="tab link-toggle @(agentLink ? "active" : "")" @onclick="ToggleAgentLink" title="Link Agents and Immortalized cards">
<i class="bi bi-link-45deg"></i>
<span>Linked</span>
<span class="toggle-switch @(agentLink ? "on" : "off")"></span>
</div>
<div class="category-divider"></div>
<button class="tab spell @(categoryFilter == "Spell" ? "active" : "")" @onclick='@(() => SetCategory("Spell"))'>
<i class="bi bi-wand"></i> Spells
</button>
</div>
<div class="filter-bar">
<div class="search-wrapper">
<i class="bi bi-search search-icon"></i>
<input @bind="search" @bind:event="oninput" class="form-control search-input"
placeholder="Search cards by name or description..."/>
@if (search.Length > 0)
{
<button class="search-clear" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<select @bind="factionFilter" class="form-select filter-select">
<option value="">All Factions</option>
@foreach (var f in factions)
{
<option value="@f">@f</option>
}
</select>
<select @bind="costFilter" class="form-select filter-select">
<option value="">All Costs</option>
@for (var i = 0; i <= 13; i++)
{
<option value="@i">@i</option>
}
</select>
<div class="sort-group d-flex gap-1">
<select @bind="sortBy" class="form-select filter-select sort-select">
<option value="Cost">Sort by Cost</option>
<option value="Name">Sort by Name</option>
<option value="Attack">Sort by Attack</option>
<option value="Health">Sort by Health</option>
<option value="Efficiency">Sort by Efficiency</option>
</select>
<button class="btn btn-outline-secondary sort-dir-btn" @onclick="() => sortDescending = !sortDescending"
title="@(sortDescending ? "Descending" : "Ascending")">
<i class="bi bi-sort-@(sortDescending ? "down" : "up")"></i>
</button>
</div>
<button class="btn @(showDetailedView ? "btn-primary" : "btn-outline-primary") view-toggle-btn"
@onclick="() => showDetailedView = !showDetailedView" title="Toggle Detailed View">
<i class="bi bi-list-ul"></i>
</button>
</div>
@if (HasActiveFilters)
{
<div class="active-filters">
<span class="filter-label">Filters:</span>
@if (categoryFilter != "")
{
<span class="filter-chip">
<i class="bi bi-tag-fill"></i> @categoryFilter
<button @onclick="ClearCategoryFilter"><i class="bi bi-x"></i></button>
</span>
}
@if (factionFilter != "")
{
<span class="filter-chip">
<i class="bi bi-flag-fill"></i> @factionFilter
<button @onclick="ClearFactionFilter"><i class="bi bi-x"></i></button>
</span>
}
@if (costFilter != "")
{
<span class="filter-chip">
<i class="bi bi-lightning-fill"></i> Cost @costFilter
<button @onclick="ClearCostFilter"><i class="bi bi-x"></i></button>
</span>
}
<button class="clear-all" @onclick="ClearFilters">Clear all</button>
</div>
}
<div class="result-count">
<span>@filteredCards.Count() card@(filteredCards.Count() != 1 ? "s" : "") found</span>
@if (HasActiveFilters || search != "")
{
<span class="text-muted">out of @allCards.Count total</span>
}
</div>
@if (filteredCards.Any())
{
<div class="card-grid @(showDetailedView ? "detailed-view" : "")">
@{ var idx = 0; }
@foreach (var card in filteredCards.Where(a => a.Category is "Agent" or "Spell" or "Immortalized"))
{
<div class="card-cell @(selectedCard == card ? "selected" : "")"
style="--i: @idx"
@onclick="() => SelectCard(card)">
<div class="card-image-wrapper">
<div class="card-shimmer"></div>
<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=%22200%22 height=%22280%22><rect fill=%22%23222244%22 width=%22200%22 height=%22280%22/><text fill=%22%23686888%22 font-size=%2214%22 x=%22100%22 y=%22140%22 text-anchor=%22middle%22 dominant-baseline=%22middle%22>No Image</text></svg>'"/>
@if (card.IsImmortalized)
{
<div class="card-immortalize-badge" title="Immortalizes"><i class="bi bi-star-fill"></i>
</div>
}
</div>
@if (showDetailedView)
{
<div class="card-label">
<div class="card-name">@card.Name</div>
<div class="card-stats">
@if (card.Attack.HasValue)
{
<span class="stat"><i class="bi bi-crosshair"></i> @card.Attack</span>
}
@if (card.Health.HasValue)
{
<span class="stat"><i class="bi bi-heart-fill"></i> @card.Health</span>
}
</div>
<div class="card-description-preview">@card.Description</div>
<div class="card-category-badge @card.Category?.ToLowerInvariant()">@card.Category</div>
</div>
}
</div>
idx++;
}
</div>
}
else
{
<div class="empty-state">
<i class="bi bi-search"></i>
<p>No cards match your filters.</p>
<button class="btn btn-outline-secondary" @onclick="ClearFilters">Reset Filters</button>
</div>
}
</div>
@if (selectedCard != null)
{
<CardDialog Card="selectedCard" OnClose="CloseDetail" OnNavigate="SelectCard"/>
}
@code {
private List<CardData> allCards = [];
private IEnumerable<CardData> filteredCards => ApplyFilters();
private string search = "";
private string categoryFilter = "";
private string immortalFilter = "";
private bool agentLink = true;
private string factionFilter = "";
private string costFilter = "";
private string sortBy = "Cost";
private bool sortDescending;
private bool showDetailedView;
private CardData? selectedCard;
private List<string> factions = [];
private bool HasActiveFilters => categoryFilter != "" || factionFilter != "" || costFilter != "" || immortalFilter != "";
protected override void OnInitialized()
{
allCards = CardDatabase.Cards;
factions = allCards
.Select(c => c.Faction)
.Where(f => f != null)
.Distinct()
.OrderBy(f => f)
.ToList()!;
}
private IEnumerable<CardData> ApplyFilters()
{
var primarySet = allCards.Where(c =>
c.MatchesSearch(search) &&
(categoryFilter == "" || c.Category == categoryFilter) &&
(factionFilter == "" || c.Faction == factionFilter) &&
(costFilter == "" || c.Cost?.ToString() == costFilter)
);
var sortedPrimary = sortBy switch
{
"Cost" => sortDescending ? primarySet.OrderByDescending(c => c.Cost) : primarySet.OrderBy(c => c.Cost),
"Efficiency" => sortDescending ? primarySet.OrderByDescending(c => c.StatEfficiency) : primarySet.OrderBy(c => c.StatEfficiency),
"Attack" => sortDescending ? primarySet.OrderByDescending(c => c.Attack) : primarySet.OrderBy(c => c.Attack),
"Health" => sortDescending ? primarySet.OrderByDescending(c => c.Health) : primarySet.OrderBy(c => c.Health),
_ => sortDescending ? primarySet.OrderByDescending(c => c.Name) : primarySet.OrderBy(c => c.Name)
};
if (!agentLink)
{
return sortedPrimary;
}
var result = new List<CardData>();
var seen = new HashSet<string>();
foreach (var card in sortedPrimary)
{
if (seen.Contains(card.Name)) continue;
// Determine the "Chain Head" (the Agent)
var head = card;
if (card.Category == "Immortalized" && card.ImmortalizeFrom != null)
{
var source = allCards.FirstOrDefault(c => c.Name == card.ImmortalizeFrom);
if (source != null) head = source;
}
// Add the chain starting from head
if (!seen.Contains(head.Name))
{
result.Add(head);
seen.Add(head.Name);
}
if (head.ImmortalizeTo != null)
{
foreach (var targetName in head.ImmortalizeTo)
{
if (seen.Contains(targetName)) continue;
var linked = allCards.FirstOrDefault(c => c.Name == targetName);
if (linked != null)
{
result.Add(linked);
seen.Add(linked.Name);
}
}
}
// Ensure the original card is added if it was somehow not part of the head's chain
if (!seen.Contains(card.Name))
{
result.Add(card);
seen.Add(card.Name);
}
}
return result;
}
private void SetImmortal(string imm)
{
immortalFilter = immortalFilter == imm ? "" : imm;
}
private void ClearImmortalFilter()
{
immortalFilter = "";
}
private void SetCategory(string cat)
{
categoryFilter = categoryFilter == cat ? "" : cat;
_ = AnalyticsService.TrackEvent("filter_category", new { category = categoryFilter });
}
private void ClearCategoryFilter()
{
categoryFilter = "";
_ = AnalyticsService.TrackEvent("filter_category", new { category = "All" });
}
private void ClearFactionFilter()
{
factionFilter = "";
}
private void ToggleAgentLink()
{
agentLink = !agentLink;
OnToggleAgentLink();
}
private void OnToggleAgentLink()
{
_ = AnalyticsService.TrackEvent("toggle_agent_link", new { enabled = agentLink });
}
private void ClearCostFilter()
{
costFilter = "";
}
private void ClearSearch()
{
search = "";
}
private void SelectCard(CardData card)
{
selectedCard = card;
_ = AnalyticsService.TrackEvent("select_item", new { item_name = card.Name, item_category = card.Category });
}
private void CloseDetail()
{
selectedCard = null;
}
private void ClearFilters()
{
search = "";
categoryFilter = "";
factionFilter = "";
costFilter = "";
}
}
+604
View File
@@ -0,0 +1,604 @@
.gallery-container {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
/* ── Header ── */
.gallery-header {
margin-bottom: 1.5rem;
}
.gallery-header h1 {
margin-bottom: 0.25rem;
}
.gallery-header p {
font-size: 0.9rem;
margin: 0;
}
/* ── Category Tabs ── */
.category-tabs {
display: flex;
gap: 0.4rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tab {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 100px;
background: transparent;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
font-family: inherit;
}
.tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--text-muted);
}
.tab.active {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--accent);
box-shadow: 0 0 12px var(--accent-glow);
}
.tab.active.agent {
border-color: #4fc3f7;
box-shadow: 0 0 12px rgba(79, 195, 247, 0.3);
}
.tab.active.spell {
border-color: #ce93d8;
box-shadow: 0 0 12px rgba(206, 147, 216, 0.3);
}
.tab.active.token {
border-color: #ffd54f;
box-shadow: 0 0 12px rgba(255, 213, 79, 0.3);
}
.tab i {
font-size: 0.9rem;
}
.category-divider {
width: 1px;
height: 1.5rem;
background: var(--border);
margin: 0 0.6rem;
align-self: center;
opacity: 0.6;
}
.tab.link-toggle {
border-style: dashed;
gap: 0.6rem;
user-select: none;
padding-right: 0.8rem;
margin-left: 0.2rem;
}
.tab.link-toggle.active {
border-style: solid;
background: rgba(108, 99, 255, 0.1);
border-color: var(--accent);
color: var(--text-primary);
}
.toggle-switch {
width: 28px;
height: 16px;
background: var(--bg-hover);
border-radius: 10px;
position: relative;
transition: all 0.2s;
border: 1px solid var(--border);
flex-shrink: 0;
}
.toggle-switch.on {
background: var(--accent);
border-color: rgba(255, 255, 255, 0.2);
}
.toggle-switch::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform 0.2s;
}
.toggle-switch.on::after {
transform: translateX(12px);
}
/* ── Filter Bar ── */
.filter-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: stretch;
}
.search-wrapper {
position: relative;
flex: 1;
min-width: 0;
}
.search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
z-index: 3;
pointer-events: none;
font-size: 0.85rem;
}
.search-input {
padding-left: 2.2rem;
padding-right: 2.2rem;
height: 100%;
}
.search-clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem;
display: flex;
align-items: center;
font-size: 0.7rem;
z-index: 3;
}
.search-clear:hover {
color: var(--text-primary);
}
.filter-select {
width: auto;
min-width: 140px;
}
/* ── Active Filter Chips ── */
.active-filters {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
font-size: 0.85rem;
}
.filter-label {
color: var(--text-muted);
font-weight: 500;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.5rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 100px;
color: var(--text-secondary);
font-size: 0.8rem;
}
.filter-chip i:first-child {
font-size: 0.7rem;
}
.filter-chip button {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
font-size: 0.65rem;
line-height: 1;
}
.filter-chip button:hover {
color: var(--text-primary);
}
.clear-all {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
font-family: inherit;
}
.clear-all:hover {
text-decoration: underline;
}
/* ── Result Count ── */
.result-count {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 1rem;
display: flex;
gap: 0.3rem;
}
/* ── Card Grid ── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 1rem;
}
.card-grid.detailed-view {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.card-grid.detailed-view .card-cell {
display: flex;
flex-direction: row;
height: 180px;
animation: none;
}
.card-grid.detailed-view .card-image-wrapper {
width: 130px;
height: 100%;
flex-shrink: 0;
}
.card-grid.detailed-view .card-label {
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding: 0.75rem;
gap: 0.4rem;
background: var(--bg-surface);
}
.card-grid.detailed-view .card-name {
font-size: 1rem;
font-weight: 700;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.2;
}
.card-stats {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
font-weight: 600;
}
.card-stats .stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.card-stats .stat i {
font-size: 0.75rem;
}
.card-stats .stat:nth-child(1) {
color: #ffab40;
}
/* Attack */
.card-stats .stat:nth-child(2) {
color: #f44336;
}
/* Health */
.card-description-preview {
font-size: 0.8rem;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
margin-top: 0.2rem;
}
.sort-group {
min-width: 200px;
}
.sort-select {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.sort-dir-btn {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
border-left: none;
background: var(--bg-surface);
}
.view-toggle-btn {
min-width: 40px;
background: var(--bg-surface);
}
/* ── Card Cell ── */
.card-cell {
cursor: pointer;
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-surface);
transition: transform 0.25s ease, box-shadow 0.25s ease;
border: 2px solid transparent;
animation: card-enter 0.4s ease-out both;
animation-delay: calc(var(--i, 0) * 25ms);
will-change: transform;
}
.card-cell:hover {
transform: translateY(-6px);
box-shadow: 0 8px 30px rgba(108, 99, 255, 0.2);
border-color: rgba(108, 99, 255, 0.2);
}
.card-cell.selected {
border-color: var(--gold);
box-shadow: 0 0 20px var(--gold-glow);
}
.card-cell.selected:hover {
box-shadow: 0 8px 30px var(--gold-glow);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(16px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── Card Image ── */
.card-image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 5 / 7;
overflow: hidden;
background: #111128;
}
.card-shimmer {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.03) 50%, transparent 100%);
background-size: 200% 100%;
animation: shimmer 2.5s infinite;
pointer-events: none;
z-index: 0;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.card-image-wrapper img {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 1;
}
.card-cost-badge {
position: absolute;
top: 0.4rem;
left: 0.4rem;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
color: var(--gold);
font-weight: 700;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
border-radius: 100px;
border: 1px solid rgba(255, 215, 0, 0.3);
z-index: 2;
line-height: 1.4;
}
.card-cost-badge::before {
content: "⚡";
margin-right: 0.15rem;
}
.card-immortalize-badge {
position: absolute;
top: 0.4rem;
right: 0.4rem;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
color: var(--gold);
font-size: 0.7rem;
padding: 0.25rem;
border-radius: 50%;
z-index: 2;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 215, 0, 0.3);
}
/* ── Card Label ── */
.card-label {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.6rem;
background: var(--bg-elevated);
min-height: 2.4rem;
}
.card-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-primary);
}
.card-category-badge {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 100px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--bg-hover);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.card-category-badge.agent {
background: rgba(79, 195, 247, 0.15);
color: #4fc3f7;
border-color: rgba(79, 195, 247, 0.3);
}
.card-category-badge.spell {
background: rgba(206, 147, 216, 0.15);
color: #ce93d8;
border-color: rgba(206, 147, 216, 0.3);
}
.card-category-badge.token {
background: rgba(255, 213, 79, 0.15);
color: #ffd54f;
border-color: rgba(255, 213, 79, 0.3);
}
/* ── Empty State ── */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
}
.empty-state i {
font-size: 2.5rem;
display: block;
margin-bottom: 1rem;
}
.empty-state p {
font-size: 1.1rem;
margin-bottom: 1rem;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.gallery-container {
padding: 1rem;
}
.filter-bar {
flex-direction: column;
}
.filter-select {
width: 100%;
min-width: 0;
}
.card-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.card-grid.detailed-view {
grid-template-columns: 1fr;
max-width: 500px;
}
.tab {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.card-grid, .card-grid.detailed-view {
grid-template-columns: 1fr;
gap: 0.5rem;
max-width: 100%;
}
.category-tabs {
gap: 0.3rem;
}
.tab {
padding: 0.35rem 0.65rem;
font-size: 0.75rem;
}
.tab i {
display: none;
}
}