From 0289063d29b13534822cbc9397308d930b0a0d43 Mon Sep 17 00:00:00 2001 From: 6d486f49 Date: Thu, 25 Jun 2026 11:27:13 -0400 Subject: [PATCH] Vibe deck improvements --- Chrono/Server/Program.cs | 1 + .../Shared/Components/CardDetailModal.razor | 150 +++++++ .../Components/CardDetailModal.razor.css | 255 ++++++++++++ Chrono/Shared/Components/CardGrid.razor | 46 +++ Chrono/Shared/Components/CardGrid.razor.css | 92 +++++ Chrono/Shared/Components/ManaCurve.razor | 20 + Chrono/Shared/Components/ManaCurve.razor.css | 43 ++ Chrono/Shared/Pages/DeckDetail.razor | 323 +-------------- Chrono/Shared/Pages/DeckDetail.razor.css | 371 +----------------- .../Shared/Services/CardRenderingService.cs | 79 ++++ Chrono/Shared/_Imports.razor | 2 + Chrono/Web/Components/CardDialog.razor | 13 +- Chrono/Web/Program.cs | 1 + 13 files changed, 716 insertions(+), 680 deletions(-) create mode 100644 Chrono/Shared/Components/CardDetailModal.razor create mode 100644 Chrono/Shared/Components/CardDetailModal.razor.css create mode 100644 Chrono/Shared/Components/CardGrid.razor create mode 100644 Chrono/Shared/Components/CardGrid.razor.css create mode 100644 Chrono/Shared/Components/ManaCurve.razor create mode 100644 Chrono/Shared/Components/ManaCurve.razor.css create mode 100644 Chrono/Shared/Services/CardRenderingService.cs diff --git a/Chrono/Server/Program.cs b/Chrono/Server/Program.cs index 263e543..0906e6d 100644 --- a/Chrono/Server/Program.cs +++ b/Chrono/Server/Program.cs @@ -7,6 +7,7 @@ builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddTelerikBlazor(); +builder.Services.AddSingleton(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); builder.Services.AddDbContext(options => diff --git a/Chrono/Shared/Components/CardDetailModal.razor b/Chrono/Shared/Components/CardDetailModal.razor new file mode 100644 index 0000000..afdbb12 --- /dev/null +++ b/Chrono/Shared/Components/CardDetailModal.razor @@ -0,0 +1,150 @@ +@namespace Shared.Components +@using Chrono.Model + +@if (Card != null) +{ +
+
+ +
+ @if (HasImage) + { +
+ @Card.Name +
+ } +
+
+

@Card.Name

+
+ @Card.Category + @if (Card.Cost.HasValue) + { + @Card.Cost + } + @if (Card.Attack.HasValue) + { + @Card.Attack + } + @if (Card.Health.HasValue) + { + @Card.Health + } + @if (Card.Speed != null) + { + @Card.Speed + } +
+
+ + @if (Card.Faction != null) + { +
+ Faction + @Card.Faction +
+ } + + @if (Card.Description != null) + { +
+ + @RenderCardDescription(Card.Description) +
+ } + + @if (Card.Set != null) + { +
+ Set + @Card.Set +
+ } + + @if (Card.Archetypes is { Count: > 0 }) + { +
+ Archetypes + @string.Join(", ", Card.Archetypes) +
+ } + + @if (Card.ImmortalizeWhen != null) + { +
+ Immortalize When + @Card.ImmortalizeWhen +
+ } + + @if (Card.HasImmortalize) + { +
+ Immortalizes To + + @foreach (var name in Card.ImmortalizeTo!) + { + var targetName = name; + var target = LookupCard(targetName); + if (target != null) + { + + } + else + { + @targetName + } + } + +
+ } + + @if (Card.ImmortalizeFrom != null) + { +
+ Immortalizes From + + @{ + var fromCard = LookupCard(Card.ImmortalizeFrom); + if (fromCard != null) + { + + } + else + { + @Card.ImmortalizeFrom + } + } + +
+ } +
+
+
+} + +@code { + [Parameter] public CardData? Card { get; set; } + [Parameter] public EventCallback OnClose { get; set; } + [Parameter] public EventCallback OnSelectCard { get; set; } + [Parameter] public RenderFragment? DescriptionRenderer { get; set; } + + private bool HasImage => Card?.ImageFile is { Length: > 0 } && Card.ImageFile != "placeholder.png"; + + private async Task Close() => await OnClose.InvokeAsync(); + + private async Task SelectCard(CardData card) => await OnSelectCard.InvokeAsync(card); + + private RenderFragment RenderCardDescription(string description) + { + if (DescriptionRenderer != null) return DescriptionRenderer(description); + return builder => builder.AddContent(0, description); + } + + private CardData? LookupCard(string cardName) + { + return CardDatabase.Cards.FirstOrDefault(c => + string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Chrono/Shared/Components/CardDetailModal.razor.css b/Chrono/Shared/Components/CardDetailModal.razor.css new file mode 100644 index 0000000..f93504d --- /dev/null +++ b/Chrono/Shared/Components/CardDetailModal.razor.css @@ -0,0 +1,255 @@ +/* ── Card Detail Overlay (inline modal) ── */ + +.detail-overlay { + position: fixed; + inset: 0; + z-index: 1040; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + animation: fade-in 0.2s ease-out; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.card-detail { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1050; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0; + max-width: 720px; + width: 92vw; + max-height: 88vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); + color: var(--text-primary); + animation: detail-enter 0.25s ease-out; +} + +@keyframes detail-enter { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.detail-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + z-index: 1; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-primary); + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.8rem; + transition: all var(--transition); +} + +.detail-close:hover { + background: rgba(255, 255, 255, 0.15); +} + +.detail-layout { + display: flex; + gap: 1.5rem; + padding: 1.5rem; +} + +.detail-image { + flex: 0 0 260px; +} + +.detail-image img { + width: 100%; + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.card-detail-noimg { + max-width: 460px; +} + +.layout-noimg { + justify-content: center; +} + +.detail-info { + flex: 1; + min-width: 0; +} + +.detail-header { + margin-bottom: 1rem; +} + +.detail-header h2 { + margin: 0 0 0.75rem; + font-size: 1.4rem; + line-height: 1.3; +} + +.detail-meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.meta-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.6rem; + border-radius: 100px; + background: var(--bg-hover); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.meta-badge.category.agent, +.meta-badge.category.immortalized { + background: rgba(79, 195, 247, 0.15); + color: #4fc3f7; + border-color: rgba(79, 195, 247, 0.3); +} + +.meta-badge.category.spell { + background: rgba(206, 147, 216, 0.15); + color: #ce93d8; + border-color: rgba(206, 147, 216, 0.3); +} + +.meta-badge.cost { + background: rgba(255, 215, 0, 0.12); + color: var(--gold); + border-color: rgba(255, 215, 0, 0.3); +} + +.meta-badge.attack { + background: rgba(239, 83, 80, 0.15); + color: #ef5350; + border-color: rgba(239, 83, 80, 0.3); +} + +.meta-badge.health { + background: rgba(102, 187, 106, 0.15); + color: #66bb6a; + border-color: rgba(102, 187, 106, 0.3); +} + +.meta-badge.speed { + background: rgba(79, 195, 247, 0.12); + color: #4fc3f7; + border-color: rgba(79, 195, 247, 0.3); +} + +.detail-field { + display: flex; + gap: 0.5rem; + margin-bottom: 0.6rem; + font-size: 0.88rem; + line-height: 1.45; +} + +.detail-field.description { + background: var(--bg-elevated); + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + border-left: 3px solid var(--accent); + margin-top: 0.25rem; +} + +.field-label { + flex-shrink: 0; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 500; + min-width: 7.5rem; + display: flex; + align-items: flex-start; + gap: 0.3rem; +} + +.detail-field.description .field-label { + min-width: auto; + color: var(--accent); +} + +.field-value { + color: var(--text-secondary); +} + +.detail-field.description .field-value { + color: var(--text-primary); + font-style: italic; +} + +.card-detail::-webkit-scrollbar { + width: 4px; +} + +.card-detail::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.inline-card-btn { + background: none; + border: none; + padding: 0; + font-family: inherit; + font-size: inherit; + font-weight: 600; + color: var(--accent); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: rgba(108, 99, 255, 0.3); + transition: color var(--transition), text-decoration-color var(--transition); +} + +.inline-card-btn:hover { + color: #7b73ff; + text-decoration-color: #7b73ff; +} + +@media (max-width: 768px) { + .detail-layout { + flex-direction: column; + padding: 1rem; + } + + .detail-image { + flex: 0 0 auto; + max-width: 180px; + margin: 0 auto; + } + + .card-detail, + .card-detail-noimg { + max-height: 90vh; + } + + .card-detail-noimg { + max-width: 92vw; + } +} diff --git a/Chrono/Shared/Components/CardGrid.razor b/Chrono/Shared/Components/CardGrid.razor new file mode 100644 index 0000000..f6d3db3 --- /dev/null +++ b/Chrono/Shared/Components/CardGrid.razor @@ -0,0 +1,46 @@ +@namespace Shared.Components +@using Chrono.Model + +
+ @foreach (var group in Cards.GroupBy(x => x)) + { + var cardName = group.Key; + var count = group.Count(); + var card = LookupCard(cardName); + + } +
+ +@code { + [Parameter, EditorRequired] public List Cards { get; set; } = []; + [Parameter] public EventCallback OnSelectCard { get; set; } + + private async Task SelectCard(CardData? card) => await OnSelectCard.InvokeAsync(card); + + private static readonly Dictionary CardLookup = CardDatabase.Cards + .Where(c => !string.IsNullOrWhiteSpace(c.Name)) + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + private CardData? LookupCard(string cardName) + { + return CardLookup.GetValueOrDefault(cardName); + } +} diff --git a/Chrono/Shared/Components/CardGrid.razor.css b/Chrono/Shared/Components/CardGrid.razor.css new file mode 100644 index 0000000..1f679f0 --- /dev/null +++ b/Chrono/Shared/Components/CardGrid.razor.css @@ -0,0 +1,92 @@ +.deck-card-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.mini-card-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-family: inherit; + display: block; +} + +.mini-card { + width: 100px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; + transition: border-color var(--transition), transform var(--transition); + position: relative; + aspect-ratio: 2.5 / 3.5; + display: flex; + flex-direction: column; +} + +.mini-card:hover { + border-color: var(--accent); + z-index: 10; +} + +.mini-card-img { + flex: 1; + overflow: hidden; + background: var(--bg-primary); + display: flex; + align-items: center; + justify-content: center; +} + +.mini-card-img img { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +.mini-card-name { + font-size: 0.7rem; + font-weight: 500; + padding: 0.25rem 0.3rem; + text-align: center; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: var(--bg-elevated); + border-top: 1px solid var(--border); +} + +.mini-card-stack { + position: relative; + width: 110px; + height: 140px; +} + +.mini-card-stack .mini-card { + position: absolute; + top: calc(var(--stack-index) * 4px); + left: calc(var(--stack-index) * 4px); + z-index: calc(10 - var(--stack-index)); +} + +.mini-card-count { + position: absolute; + top: 0.25rem; + right: 0.25rem; + background: var(--accent); + color: white; + font-size: 0.65rem; + font-weight: 800; + padding: 0.1rem 0.3rem; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + z-index: 20; +} + +.mini-card-btn.is-stacked:hover .mini-card { + transform: translate(calc(var(--stack-index) * 2px), calc(var(--stack-index) * -2px)); +} diff --git a/Chrono/Shared/Components/ManaCurve.razor b/Chrono/Shared/Components/ManaCurve.razor new file mode 100644 index 0000000..34475cb --- /dev/null +++ b/Chrono/Shared/Components/ManaCurve.razor @@ -0,0 +1,20 @@ +@namespace Shared.Components + +
+ @foreach (var entry in Distribution) + { + var height = MaxCount > 0 ? (entry.Count * 100 / MaxCount) : 0; +
+
@entry.Count
+
+
@(entry.Cost >= MaxCostLabel ? $"{MaxCostLabel}+" : entry.Cost.ToString())
+
+ } +
+ +@code { + [Parameter, EditorRequired] public List<(int Cost, int Count)> Distribution { get; set; } = []; + [Parameter] public int MaxCostLabel { get; set; } = 10; + + private int MaxCount => Distribution.Count > 0 ? Distribution.Max(x => x.Count) : 0; +} diff --git a/Chrono/Shared/Components/ManaCurve.razor.css b/Chrono/Shared/Components/ManaCurve.razor.css new file mode 100644 index 0000000..403c07b --- /dev/null +++ b/Chrono/Shared/Components/ManaCurve.razor.css @@ -0,0 +1,43 @@ +.mana-curve { + display: flex; + align-items: flex-end; + gap: 0.5rem; + height: 80px; + margin-top: 1rem; + padding: 0 0.5rem; +} + +.mana-bar-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + justify-content: flex-end; +} + +.mana-bar-label { + font-size: 0.7rem; + color: var(--text-muted); + margin-bottom: 0.2rem; +} + +.mana-bar { + width: 100%; + background: var(--accent); + border-radius: 4px 4px 0 0; + min-height: 4px; + transition: height 0.3s ease; +} + +.mana-cost { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + margin-top: 0.4rem; + background: var(--bg-elevated); + width: 100%; + text-align: center; + border-radius: 4px; + padding: 0.1rem 0; +} diff --git a/Chrono/Shared/Pages/DeckDetail.razor b/Chrono/Shared/Pages/DeckDetail.razor index caaa6e9..847e717 100644 --- a/Chrono/Shared/Pages/DeckDetail.razor +++ b/Chrono/Shared/Pages/DeckDetail.razor @@ -1,4 +1,5 @@ @namespace Shared.Pages +@inject CardRenderingService CardRenderer @page "/decks/{Name}" @deck?.Name @@ -39,41 +40,14 @@ } - @{ - var distribution = GetManaDistribution(); - var maxCount = distribution.Max(x => x.Count); - } -
- @foreach (var entry in distribution) - { - var height = maxCount > 0 ? (entry.Count * 100 / maxCount) : 0; -
-
@entry.Count
-
-
@(entry.Cost >= 10 ? "10+" : entry.Cost.ToString())
-
- } -
+ @if (deck.Keycards.Count > 0) {

Keycards

-
- @foreach (var cardName in deck.Keycards) - { - var card = LookupCard(cardName); - - } -
+
} @@ -81,47 +55,13 @@ {

Divers

-
- @foreach (var cardName in deck.Divers) - { - var card = LookupCard(cardName); - - } -
+
}

Cards

-
- @foreach (var group in deck.Cards.GroupBy(x => x)) - { - var cardName = group.Key; - var count = group.Count(); - var card = LookupCard(cardName); - - } -
+
@if (deck.Description != null) @@ -129,7 +69,7 @@

Description

- @RenderDescription(deck.Description) + @CardRenderer.RenderDescription(deck.Description, SelectCard)
} @@ -137,128 +77,10 @@ } -@if (selectedCard != null) -{ -
-
- -
- @if (HasImage) - { -
- @selectedCard.Name -
- } -
-
-

@selectedCard.Name

-
- @selectedCard.Category - @if (selectedCard.Cost.HasValue) - { - @selectedCard.Cost - } - @if (selectedCard.Attack.HasValue) - { - @selectedCard.Attack - } - @if (selectedCard.Health.HasValue) - { - @selectedCard.Health - } - @if (selectedCard.Speed != null) - { - @selectedCard.Speed - } -
-
- - @if (selectedCard.Faction != null) - { -
- Faction - @selectedCard.Faction -
- } - - @if (selectedCard.Description != null) - { -
- - @RenderCardDescription(selectedCard.Description) -
- } - - @if (selectedCard.Set != null) - { -
- Set - @selectedCard.Set -
- } - - @if (selectedCard.Archetypes is { Count: > 0 }) - { -
- Archetypes - @string.Join(", ", selectedCard.Archetypes) -
- } - - @if (selectedCard.ImmortalizeWhen != null) - { -
- Immortalize When - @selectedCard.ImmortalizeWhen -
- } - - @if (selectedCard.HasImmortalize) - { -
- Immortalizes To - - @foreach (var name in selectedCard.ImmortalizeTo!) - { - var targetName = name; - var target = LookupCard(targetName); - if (target != null) - { - - } - else - { - @targetName - } - } - -
- } - - @if (selectedCard.ImmortalizeFrom != null) - { -
- Immortalizes From - - @{ - var fromCard = LookupCard(selectedCard.ImmortalizeFrom); - if (fromCard != null) - { - - } - else - { - @selectedCard.ImmortalizeFrom - } - } - -
- } -
-
-
-} + @code { [Parameter] public string Name { get; set; } = ""; @@ -274,10 +96,14 @@ deck = DeckDatabase.Decks.FirstOrDefault(d => d.IsVisible && d.Name == decoded); } + private static readonly Dictionary CardLookup = CardDatabase.Cards + .Where(c => !string.IsNullOrWhiteSpace(c.Name)) + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + private CardData? LookupCard(string cardName) { - return CardDatabase.Cards.FirstOrDefault(c => - string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase)); + return CardLookup.GetValueOrDefault(cardName); } private void SelectCard(CardData? card) @@ -309,121 +135,4 @@ return counts.OrderBy(x => x.Key).Select(x => (x.Key, x.Value)).ToList(); } - - private RenderFragment RenderDescription(string description) - { - return builder => - { - var cardNames = CardDatabase.Cards - .Select(c => c.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .OrderByDescending(n => n.Length) - .ToList(); - - int sequence = 0; - var paragraphs = description.Split('\n', StringSplitOptions.RemoveEmptyEntries); - - foreach (var paragraph in paragraphs) - { - builder.OpenElement(sequence++, "p"); - - var currentText = paragraph; - while (!string.IsNullOrEmpty(currentText)) - { - int bestMatchIndex = -1; - string? bestMatchName = null; - - foreach (var name in cardNames) - { - int index = currentText.IndexOf(name, StringComparison.OrdinalIgnoreCase); - if (index != -1 && (bestMatchIndex == -1 || index < bestMatchIndex)) - { - bestMatchIndex = index; - bestMatchName = name; - } - } - - if (bestMatchIndex != -1 && bestMatchName != null) - { - if (bestMatchIndex > 0) - { - builder.AddContent(sequence++, currentText.Substring(0, bestMatchIndex)); - } - - var card = LookupCard(bestMatchName); - builder.OpenElement(sequence++, "button"); - builder.AddAttribute(sequence++, "class", "inline-card-btn"); - builder.AddAttribute(sequence++, "onclick", EventCallback.Factory.Create(this, () => SelectCard(card))); - builder.AddContent(sequence++, bestMatchName); - builder.CloseElement(); - - currentText = currentText.Substring(bestMatchIndex + bestMatchName.Length); - } - else - { - builder.AddContent(sequence++, currentText); - currentText = string.Empty; - } - } - - builder.CloseElement(); - } - }; - } - - private RenderFragment RenderCardDescription(string description) => builder => - { - var cardNames = CardDatabase.Cards - .Select(c => c.Name) - .Where(n => !string.IsNullOrWhiteSpace(n)) - .OrderByDescending(n => n.Length) - .ToList(); - - int seq = 0; - var remaining = description; - - while (!string.IsNullOrEmpty(remaining)) - { - int bestIdx = -1; - string? bestName = null; - - foreach (var name in cardNames) - { - int idx = remaining.IndexOf(name, StringComparison.OrdinalIgnoreCase); - if (idx != -1 && (bestIdx == -1 || idx < bestIdx)) - { - bestIdx = idx; - bestName = name; - } - } - - if (bestIdx != -1 && bestName != null) - { - if (bestIdx > 0) - builder.AddContent(seq++, remaining[..bestIdx]); - - var card = LookupCard(bestName); - if (card != null) - { - builder.OpenElement(seq++, "button"); - builder.AddAttribute(seq++, "class", "inline-card-btn"); - builder.AddAttribute(seq++, "onclick", - EventCallback.Factory.Create(this, () => SelectCard(card))); - builder.AddContent(seq++, bestName); - builder.CloseElement(); - } - else - { - builder.AddContent(seq++, bestName); - } - - remaining = remaining[(bestIdx + bestName.Length)..]; - } - else - { - builder.AddContent(seq++, remaining); - remaining = ""; - } - } - }; } diff --git a/Chrono/Shared/Pages/DeckDetail.razor.css b/Chrono/Shared/Pages/DeckDetail.razor.css index 474a9e3..d36f681 100644 --- a/Chrono/Shared/Pages/DeckDetail.razor.css +++ b/Chrono/Shared/Pages/DeckDetail.razor.css @@ -62,50 +62,6 @@ margin: 0 0 1.5rem; } -.mana-curve { - display: flex; - align-items: flex-end; - gap: 0.5rem; - height: 80px; - margin-top: 1rem; - padding: 0 0.5rem; -} - -.mana-bar-container { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - height: 100%; - justify-content: flex-end; -} - -.mana-bar-label { - font-size: 0.7rem; - color: var(--text-muted); - margin-bottom: 0.2rem; -} - -.mana-bar { - width: 100%; - background: var(--accent); - border-radius: 4px 4px 0 0; - min-height: 4px; - transition: height 0.3s ease; -} - -.mana-cost { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); - margin-top: 0.4rem; - background: var(--bg-elevated); - width: 100%; - text-align: center; - border-radius: 4px; - padding: 0.1rem 0; -} - .deck-section { margin-bottom: 2rem; } @@ -124,99 +80,6 @@ color: var(--text-primary); } -.deck-card-row { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - -.mini-card-btn { - background: none; - border: none; - padding: 0; - cursor: pointer; - font-family: inherit; - display: block; -} - -.mini-card { - width: 100px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - overflow: hidden; - transition: border-color var(--transition), transform var(--transition); - position: relative; - aspect-ratio: 2.5 / 3.5; - display: flex; - flex-direction: column; -} - -.mini-card:hover { - border-color: var(--accent); - z-index: 10; -} - -.mini-card-img { - flex: 1; - overflow: hidden; - background: var(--bg-primary); - display: flex; - align-items: center; - justify-content: center; -} - -.mini-card-img img { - width: 100%; - height: 100%; - object-fit: contain; - background: #000; -} - -.mini-card-name { - font-size: 0.7rem; - font-weight: 500; - padding: 0.25rem 0.3rem; - text-align: center; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - background: var(--bg-elevated); - border-top: 1px solid var(--border); -} - -.mini-card-stack { - position: relative; - width: 110px; - height: 140px; -} - -.mini-card-stack .mini-card { - position: absolute; - top: calc(var(--stack-index) * 4px); - left: calc(var(--stack-index) * 4px); - z-index: calc(10 - var(--stack-index)); -} - -.mini-card-count { - position: absolute; - top: 0.25rem; - right: 0.25rem; - background: var(--accent); - color: white; - font-size: 0.65rem; - font-weight: 800; - padding: 0.1rem 0.3rem; - border-radius: 4px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - z-index: 20; -} - -.mini-card-btn.is-stacked:hover .mini-card { - transform: translate(calc(var(--stack-index) * 2px), calc(var(--stack-index) * -2px)); -} - .deck-description { font-size: 0.95rem; line-height: 1.7; @@ -244,220 +107,6 @@ margin-bottom: 1rem; } -/* ── Card Detail Overlay (inline modal) ── */ - -.detail-overlay { - position: fixed; - inset: 0; - z-index: 1040; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); - animation: fade-in 0.2s ease-out; -} - -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -.card-detail { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - z-index: 1050; - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: 16px; - padding: 0; - max-width: 720px; - width: 92vw; - max-height: 88vh; - overflow-y: auto; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); - color: var(--text-primary); - animation: detail-enter 0.25s ease-out; -} - -@keyframes detail-enter { - from { - opacity: 0; - transform: translate(-50%, -50%) scale(0.92); - } - to { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } -} - -.detail-close { - position: absolute; - top: 0.75rem; - right: 0.75rem; - z-index: 1; - background: rgba(0, 0, 0, 0.5); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--text-primary); - width: 2rem; - height: 2rem; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 0.8rem; - transition: all var(--transition); -} - -.detail-close:hover { - background: rgba(255, 255, 255, 0.15); -} - -.detail-layout { - display: flex; - gap: 1.5rem; - padding: 1.5rem; -} - -.detail-image { - flex: 0 0 260px; -} - -.detail-image img { - width: 100%; - border-radius: var(--radius); - box-shadow: var(--shadow); -} - -.card-detail-noimg { - max-width: 460px; -} - -.layout-noimg { - justify-content: center; -} - -.detail-info { - flex: 1; - min-width: 0; -} - -.detail-header { - margin-bottom: 1rem; -} - -.detail-header h2 { - margin: 0 0 0.75rem; - font-size: 1.4rem; - line-height: 1.3; -} - -.detail-meta { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; -} - -.meta-badge { - display: inline-flex; - align-items: center; - gap: 0.3rem; - font-size: 0.75rem; - font-weight: 600; - padding: 0.25rem 0.6rem; - border-radius: 100px; - background: var(--bg-hover); - color: var(--text-secondary); - border: 1px solid var(--border); -} - -.meta-badge.category.agent, -.meta-badge.category.immortalized { - background: rgba(79, 195, 247, 0.15); - color: #4fc3f7; - border-color: rgba(79, 195, 247, 0.3); -} - -.meta-badge.category.spell { - background: rgba(206, 147, 216, 0.15); - color: #ce93d8; - border-color: rgba(206, 147, 216, 0.3); -} - -.meta-badge.cost { - background: rgba(255, 215, 0, 0.12); - color: var(--gold); - border-color: rgba(255, 215, 0, 0.3); -} - -.meta-badge.attack { - background: rgba(239, 83, 80, 0.15); - color: #ef5350; - border-color: rgba(239, 83, 80, 0.3); -} - -.meta-badge.health { - background: rgba(102, 187, 106, 0.15); - color: #66bb6a; - border-color: rgba(102, 187, 106, 0.3); -} - -.meta-badge.speed { - background: rgba(79, 195, 247, 0.12); - color: #4fc3f7; - border-color: rgba(79, 195, 247, 0.3); -} - -.detail-field { - display: flex; - gap: 0.5rem; - margin-bottom: 0.6rem; - font-size: 0.88rem; - line-height: 1.45; -} - -.detail-field.description { - background: var(--bg-elevated); - padding: 0.6rem 0.75rem; - border-radius: var(--radius-sm); - border-left: 3px solid var(--accent); - margin-top: 0.25rem; -} - -.field-label { - flex-shrink: 0; - color: var(--text-muted); - font-size: 0.8rem; - font-weight: 500; - min-width: 7.5rem; - display: flex; - align-items: flex-start; - gap: 0.3rem; -} - -.detail-field.description .field-label { - min-width: auto; - color: var(--accent); -} - -.field-value { - color: var(--text-secondary); -} - -.detail-field.description .field-value { - color: var(--text-primary); - font-style: italic; -} - -.card-detail::-webkit-scrollbar { - width: 4px; -} - -.card-detail::-webkit-scrollbar-thumb { - background: var(--border); - border-radius: 2px; -} - .inline-card-btn { background: none; border: none; @@ -479,23 +128,7 @@ } @media (max-width: 768px) { - .detail-layout { - flex-direction: column; - padding: 1rem; - } - - .detail-image { - flex: 0 0 auto; - max-width: 180px; - margin: 0 auto; - } - - .card-detail, - .card-detail-noimg { - max-height: 90vh; - } - - .card-detail-noimg { - max-width: 92vw; + .back-link { + margin-bottom: 1rem; } } diff --git a/Chrono/Shared/Services/CardRenderingService.cs b/Chrono/Shared/Services/CardRenderingService.cs new file mode 100644 index 0000000..f4248a4 --- /dev/null +++ b/Chrono/Shared/Services/CardRenderingService.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Components; +using Chrono.Model; +using System.Text.RegularExpressions; + +namespace Shared.Services; + +public class CardRenderingService +{ + private readonly List _cardNames; + private readonly Dictionary _cardLookup; + private readonly Regex _nameRegex; + + public CardRenderingService() + { + var allCards = CardDatabase.Cards + .Where(c => !string.IsNullOrWhiteSpace(c.Name)) + .ToList(); + + _cardLookup = allCards + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + _cardNames = allCards + .Select(c => c.Name) + .OrderByDescending(n => n.Length) + .ToList(); + + var pattern = string.Join("|", _cardNames.Select(n => $@"\b{Regex.Escape(n)}\b")); + _nameRegex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + + public RenderFragment RenderDescription(string description, Action onCardClick) + { + return builder => + { + int sequence = 0; + var paragraphs = description.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + foreach (var paragraph in paragraphs) + { + builder.OpenElement(sequence++, "p"); + + var matches = _nameRegex.Matches(paragraph); + int lastIndex = 0; + + foreach (Match match in matches) + { + if (match.Index > lastIndex) + { + builder.AddContent(sequence++, paragraph.Substring(lastIndex, match.Index - lastIndex)); + } + + var cardName = match.Value; + var card = LookupCard(cardName); + + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "class", "inline-card-btn"); + builder.AddAttribute(sequence++, "onclick", EventCallback.Factory.Create(this, () => onCardClick(card))); + builder.AddContent(sequence++, cardName); + builder.CloseElement(); + + lastIndex = match.Index + match.Length; + } + + if (lastIndex < paragraph.Length) + { + builder.AddContent(sequence++, paragraph.Substring(lastIndex)); + } + + builder.CloseElement(); + } + }; + } + + private CardData? LookupCard(string cardName) + { + return _cardLookup.GetValueOrDefault(cardName); + } +} diff --git a/Chrono/Shared/_Imports.razor b/Chrono/Shared/_Imports.razor index b0bb168..9708c72 100644 --- a/Chrono/Shared/_Imports.razor +++ b/Chrono/Shared/_Imports.razor @@ -11,3 +11,5 @@ @using Telerik.Blazor @using Telerik.Blazor.Components @using Telerik.DataSource +@using Shared.Components +@using Shared.Services diff --git a/Chrono/Web/Components/CardDialog.razor b/Chrono/Web/Components/CardDialog.razor index 68154bf..31850f9 100644 --- a/Chrono/Web/Components/CardDialog.razor +++ b/Chrono/Web/Components/CardDialog.razor @@ -227,11 +227,16 @@ foreach (var name in cardNames) { - int idx = remaining.IndexOf(name, StringComparison.OrdinalIgnoreCase); - if (idx != -1 && (bestIdx == -1 || idx < bestIdx)) + var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(name)}\b"; + var match = System.Text.RegularExpressions.Regex.Match(remaining, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (match.Success) { - bestIdx = idx; - bestName = name; + if (bestIdx == -1 || match.Index < bestIdx) + { + bestIdx = match.Index; + bestName = match.Value; + } } } diff --git a/Chrono/Web/Program.cs b/Chrono/Web/Program.cs index 3255890..88a415f 100644 --- a/Chrono/Web/Program.cs +++ b/Chrono/Web/Program.cs @@ -8,5 +8,6 @@ builder.RootComponents.Add("head::after"); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddTelerikBlazor(); +builder.Services.AddSingleton(); await builder.Build().RunAsync(); \ No newline at end of file