Vibed deck viewer

This commit is contained in:
2026-06-18 13:30:46 -04:00
parent c3c8aa1403
commit 111bc36fd4
12 changed files with 1062 additions and 34 deletions
+155 -2
View File
@@ -30,7 +30,7 @@ foreach (var file in mdFiles)
if (endIndex < 0)
continue;
var frontmatter = content[3..endIndex].Trim();
var frontmatter = content[3..endIndex].Trim().Replace("\r\n", "\n").Replace("\r", "\n");
var yaml = ParseYaml(frontmatter);
var name = Path.GetFileNameWithoutExtension(file);
@@ -117,6 +117,75 @@ writer.WriteLine(" ];");
writer.WriteLine("}");
Console.WriteLine($"Generated {cards.Count} cards in {generatedFile}");
// ── Decks ──
var decksDir = Path.Combine(docsDir, "Decks");
var deckFiles = Directory.Exists(decksDir) ? Directory.GetFiles(decksDir, "*.md") : [];
var decks = new List<DeckData>();
foreach (var file in deckFiles)
{
var content = Encoding.UTF8.GetString(File.ReadAllBytes(file));
if (!content.StartsWith("---")) continue;
var endIndex = content.IndexOf("---", 3, StringComparison.Ordinal);
if (endIndex < 0) continue;
var frontmatter = content[3..endIndex].Trim().Replace("\r\n", "\n").Replace("\r", "\n");
var yaml = ParseYaml(frontmatter);
var name = Path.GetFileNameWithoutExtension(file);
var isVisible = yaml.GetValueOrDefault("isVisible") == "true";
var deck = new DeckData
{
Name = name,
Cards = ParseList(yaml, "cards").Select(s => StripWikiLink(s) ?? "").Where(s => s != "").ToList(),
Keycards = ParseList(yaml, "keycards").Select(s => StripWikiLink(s) ?? "").Where(s => s != "").ToList(),
Divers = ParseList(yaml, "divers").Select(s => StripWikiLink(s) ?? "").Where(s => s != "").ToList(),
Description = StripWikiLinks(NullIfNa(yaml.GetValueOrDefault("description")))?.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n\n", "\n").Replace("\n\n", "\n"),
Factions = ParseList(yaml, "factions").Select(s => StripWikiLink(s) ?? "").Where(s => s != "").ToList(),
IsVisible = isVisible,
};
decks.Add(deck);
}
Console.WriteLine($"Parsed {decks.Count} deck files");
// Generate Decks.g.cs
var deckGeneratedFile = Path.Combine(repoRoot, "Chrono", "Web", "Generated", "Decks.g.cs");
Directory.CreateDirectory(Path.GetDirectoryName(deckGeneratedFile)!);
using var deckWriter = new StreamWriter(deckGeneratedFile, false, Encoding.UTF8);
deckWriter.WriteLine("// <auto-generated/>");
deckWriter.WriteLine("#nullable enable");
deckWriter.WriteLine();
deckWriter.WriteLine("namespace Chrono.Model;");
deckWriter.WriteLine();
deckWriter.WriteLine("public static class DeckDatabase");
deckWriter.WriteLine("{");
deckWriter.WriteLine(" public static readonly System.Collections.Generic.List<DeckData> Decks =");
deckWriter.WriteLine(" [");
for (var i = 0; i < decks.Count; i++)
{
var d = decks[i];
deckWriter.WriteLine(" new()");
deckWriter.WriteLine(" {");
WriteProp(deckWriter, "Name", d.Name, 3);
WriteListProp(deckWriter, "Cards", d.Cards, 3);
WriteListProp(deckWriter, "Keycards", d.Keycards, 3);
WriteListProp(deckWriter, "Divers", d.Divers, 3);
WriteStrProp(deckWriter, "Description", d.Description, 3);
WriteListProp(deckWriter, "Factions", d.Factions, 3);
deckWriter.WriteLine($" IsVisible = {(d.IsVisible ? "true" : "false")},");
var comma = i < decks.Count - 1 ? "," : "";
deckWriter.WriteLine($" }}{comma}");
}
deckWriter.WriteLine(" ];");
deckWriter.WriteLine("}");
Console.WriteLine($"Generated {decks.Count} decks in {deckGeneratedFile}");
return 0;
// --- Helpers ---
@@ -181,10 +250,48 @@ static Dictionary<string, string> ParseYaml(string yaml)
var lines = yaml.Split('\n');
string? currentKey = null;
var listBuffer = new List<string>();
var inBlockScalar = false;
string? blockScalarKey = null;
var blockScalarLines = new List<string>();
string? quoteKey = null;
var quoteLines = new List<string>();
foreach (var line in lines)
{
var trimmed = line.Trim();
if (inBlockScalar)
{
if (line.Length == 0 || line[0] == ' ' || line[0] == '\t')
{
blockScalarLines.Add(trimmed);
continue;
}
if (blockScalarKey != null)
dict[blockScalarKey] = string.Join("\n", blockScalarLines);
inBlockScalar = false;
blockScalarKey = null;
blockScalarLines.Clear();
}
if (quoteKey != null)
{
if (line.Length == 0 || line[0] == ' ' || line[0] == '\t')
{
quoteLines.Add(trimmed);
if (trimmed.EndsWith("\""))
{
dict[quoteKey] = string.Join("\n", quoteLines.Select(UnescapeYaml)).TrimEnd('"');
quoteKey = null;
quoteLines.Clear();
}
continue;
}
dict[quoteKey] = string.Join("\n", quoteLines.Select(UnescapeYaml));
quoteKey = null;
quoteLines.Clear();
}
if (trimmed.Length == 0) continue;
if (trimmed.StartsWith("- "))
@@ -205,16 +312,62 @@ static Dictionary<string, string> ParseYaml(string yaml)
currentKey = trimmed[..colonIndex].Trim();
var value = trimmed[(colonIndex + 1)..].Trim();
if (value.Length == 0) continue;
if (value is "|" or "|-")
{
inBlockScalar = true;
blockScalarKey = currentKey;
blockScalarLines.Clear();
continue;
}
if (value.StartsWith("\"") && !value.EndsWith("\""))
{
quoteKey = currentKey;
quoteLines.Clear();
quoteLines.Add(value.TrimStart('"'));
continue;
}
dict[currentKey] = value;
}
if (listBuffer.Count > 0 && currentKey != null)
dict[currentKey] = string.Join("\n", listBuffer);
if (inBlockScalar && blockScalarKey != null)
dict[blockScalarKey] = string.Join("\n", blockScalarLines);
if (quoteKey != null)
dict[quoteKey] = string.Join("\n", quoteLines.Select(UnescapeYaml));
return dict;
}
static string UnescapeYaml(string s)
{
var sb = new StringBuilder();
for (var i = 0; i < s.Length; i++)
{
if (s[i] == '\\' && i + 1 < s.Length)
{
switch (s[i + 1])
{
case 'r': sb.Append('\r'); i++; break;
case 'n': sb.Append('\n'); i++; break;
case 't': sb.Append('\t'); i++; break;
case '\\': sb.Append('\\'); i++; break;
case '"': sb.Append('"'); i++; break;
default: sb.Append(s[i]); break;
}
}
else
{
sb.Append(s[i]);
}
}
return sb.ToString();
}
static void WriteProp(StreamWriter w, string name, string value, int indent)
{
w.WriteLine($"{new string(' ', indent * 4)}{name} = {ToLiteral(value)},");
+12
View File
@@ -0,0 +1,12 @@
namespace Chrono.Model;
public class DeckData
{
public string Name { get; init; } = "";
public List<string> Cards { get; init; } = [];
public List<string> Keycards { get; init; } = [];
public List<string> Divers { get; init; } = [];
public string? Description { get; init; }
public List<string> Factions { get; init; } = [];
public bool IsVisible { get; init; }
}
+9
View File
@@ -2028,6 +2028,7 @@ public static class CardDatabase
ImmortalizeTo = [
],
ImmortalizeFrom = "Gardener Apprentice",
ImmortalizeWhen = "",
},
new()
{
@@ -2885,6 +2886,14 @@ public static class CardDatabase
ImageFile = "Overmind's Guilt.png",
},
new()
{
Name = "Overpower",
Category = "Keyword",
Description = "Excess damage beyond the Durability of this Agent's blocker is dealt directly to the enemy core.",
Archetypes = [
],
},
new()
{
Name = "Overseer of Trials",
Category = "Agent",
+77
View File
@@ -0,0 +1,77 @@
// <auto-generated/>
#nullable enable
namespace Chrono.Model;
public static class DeckDatabase
{
public static readonly System.Collections.Generic.List<DeckData> Decks =
[
new()
{
Name = "Big Energy",
Cards = [
"Brilliant Martyr",
"Kinetic Absorber",
"Hidden Locus",
"Suncursed Conduit",
"Swashbuckling Diehard",
"Debris Collector",
"Paradox Flow",
"Starfueled Medics",
"Lumbering Starseeker",
"Novathermal Mining",
"Radiant Channeling",
"Supernova",
"Gunnery Captain",
"Lightsteel Colossus",
"Devourer Spawn",
"Army of the Sun",
],
Keycards = [
"Kinetic Absorber",
"Hidden Locus",
"Debris Collector",
"Lumbering Starseeker",
"Lightsteel Colossus",
],
Divers = [
"Peaceful Synthesizer",
"Limit Breaker",
],
Description = "The idea of this deck is to go heavy on stat-efficient cards. Like 2 for a 2/3 Kinetic Absorber, 5/6 Lumbering Starseeker, and 8 for a 9/14 Lightsteel Colossus. Debris Collector is also quite powerful and will Immortalize into a Living Comet 10/10 with Overpower pretty reliably, but it's also your only stat Mute target, so it might be a bit trash if that's popular in the meta.\nYou got Devourer Spawn to help push for lethal. It's going to have a bunch of keywords on it, given how popular timelines are in the meta.\nYou need to Overflow your mana once in a while, or some of your Supernova removal will be useless. Be pass heavy.\nYou're going to win on very low health if you win. Such is life.\nNo card draw, so Army of the Sun is your hope if things go long. At the worst, it's a spell that summons a big unit right away if it goes off.\nStarfueled Medics and Swashbuckling Diehard are just there to take up space and be mid-range drops. And technically, they can be win conditions, I suppose.\nAttack aggressively with Brilliant Martyr. You really want Star Siphon to ramp.\nAs divers, Peaceful Synthesizer is a 1 drop that will quickly turn into a 3/3. Can't get more stat efficient than that. And Limit Breaker is an early-game growing threat that will demand an answer from the enemy.",
Factions = [
"Sungrace",
],
IsVisible = true,
},
new()
{
Name = "Rewind Me",
Cards = [
"Curious Acolyte",
"Chronicle of the One",
"Prayer of Rescue",
"Backhand",
"Snap Back",
"Sunbringer Artillerist",
"Sunshock",
"Temple Analyst",
"Holy Cleaner",
"Balance Blade",
"Rescind Authorization",
"Out of Line",
"Divergence Assassin",
"Chronal Quarantine",
"Holder of the Instruments",
],
Keycards = [
],
Divers = [
],
Factions = [
],
IsVisible = false,
}
];
}
+5
View File
@@ -24,6 +24,11 @@
<i class="bi bi-people-fill nav-icon"></i> Agents
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="decks">
<i class="bi bi-journal-text nav-icon"></i> Decks
</NavLink>
</div>
</nav>
</div>
+223
View File
@@ -0,0 +1,223 @@
@page "/decks/{Name}"
<PageTitle>@deck?.Name</PageTitle>
<div class="deck-detail-page">
<a class="back-link" href="/decks"><i class="bi bi-arrow-left"></i> Back to Decks</a>
@if (deck == null)
{
<div class="empty-state">
<i class="bi bi-exclamation-circle"></i>
<p>Deck not found.</p>
</div>
}
else
{
<article class="deck-article">
<header class="deck-header">
<h1>@deck.Name</h1>
@if (deck.Factions.Count > 0)
{
<div class="deck-factions">
@foreach (var f in deck.Factions)
{
<span class="faction-badge">@f</span>
}
</div>
}
<p class="deck-card-count">@deck.Cards.Count cards</p>
</header>
@if (deck.Keycards.Count > 0)
{
<section class="deck-section">
<h2><i class="bi bi-star-fill" style="color: var(--gold);"></i> Keycards</h2>
<div class="deck-card-row">
@foreach (var cardName in deck.Keycards)
{
var card = LookupCard(cardName);
<button class="mini-card-btn" @onclick="() => SelectCard(card)">
<div class="mini-card">
<div class="mini-card-img">
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName" loading="lazy"/>
</div>
<div class="mini-card-name">@cardName</div>
</div>
</button>
}
</div>
</section>
}
@if (deck.Divers.Count > 0)
{
<section class="deck-section">
<h2><i class="bi bi-shuffle"></i> Divers</h2>
<div class="deck-card-row">
@foreach (var cardName in deck.Divers)
{
var card = LookupCard(cardName);
<button class="mini-card-btn" @onclick="() => SelectCard(card)">
<div class="mini-card">
<div class="mini-card-img">
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName" loading="lazy"/>
</div>
<div class="mini-card-name">@cardName</div>
</div>
</button>
}
</div>
</section>
}
<section class="deck-section">
<h2><i class="bi bi-collection-fill"></i> Cards</h2>
<div class="deck-card-row">
@foreach (var cardName in deck.Cards)
{
var card = LookupCard(cardName);
<button class="mini-card-btn" @onclick="() => SelectCard(card)">
<div class="mini-card">
<div class="mini-card-img">
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName" loading="lazy"/>
</div>
<div class="mini-card-name">@cardName</div>
</div>
</button>
}
</div>
</section>
@if (deck.Description != null)
{
<section class="deck-section description">
<h2><i class="bi bi-chat-quote-fill"></i> Description</h2>
<div class="deck-description">
@foreach (var paragraph in deck.Description.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
<p>@paragraph</p>
}
</div>
</section>
}
</article>
}
</div>
@if (selectedCard != null)
{
<div class="modal-backdrop" @onclick="CloseDetail"></div>
<div class="card-detail">
<button class="detail-close" @onclick="CloseDetail"><i class="bi bi-x-lg"></i></button>
<div class="detail-layout">
<div class="detail-image">
<img src="@selectedCard.ImagePath" alt="@selectedCard.Name"/>
</div>
<div class="detail-info">
<div class="detail-header">
<h2>@selectedCard.Name</h2>
<div class="detail-meta">
<span class="meta-badge category @selectedCard.Category?.ToLowerInvariant()">@selectedCard.Category</span>
@if (selectedCard.Cost.HasValue)
{
<span class="meta-badge cost"><i class="bi bi-lightning-fill"></i> @selectedCard.Cost</span>
}
@if (selectedCard.Attack.HasValue)
{
<span class="meta-badge attack"><i class="bi bi-crosshair"></i> @selectedCard.Attack</span>
}
@if (selectedCard.Health.HasValue)
{
<span class="meta-badge health"><i class="bi bi-heart-fill"></i> @selectedCard.Health</span>
}
@if (selectedCard.Speed != null)
{
<span class="meta-badge speed"><i class="bi bi-wind"></i> @selectedCard.Speed</span>
}
</div>
</div>
@if (selectedCard.Faction != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-flag-fill"></i> Faction</span>
<span class="field-value">@selectedCard.Faction</span>
</div>
}
@if (selectedCard.Description != null)
{
<div class="detail-field description">
<span class="field-label"><i class="bi bi-chat-quote-fill"></i></span>
<span class="field-value">@selectedCard.Description</span>
</div>
}
@if (selectedCard.Set != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-collection"></i> Set</span>
<span class="field-value">@selectedCard.Set</span>
</div>
}
@if (selectedCard.Archetypes is { Count: > 0 })
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-layers-fill"></i> Archetypes</span>
<span class="field-value">@string.Join(", ", selectedCard.Archetypes)</span>
</div>
}
@if (selectedCard.ImmortalizeWhen != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-star-fill"></i> Immortalize When</span>
<span class="field-value">@selectedCard.ImmortalizeWhen</span>
</div>
}
@if (selectedCard.HasImmortalize)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-arrow-right-circle-fill"></i> Immortalizes To</span>
<span class="field-value">@string.Join(", ", selectedCard.ImmortalizeTo!)</span>
</div>
}
@if (selectedCard.ImmortalizeFrom != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-arrow-left-circle-fill"></i> Immortalizes From</span>
<span class="field-value">@selectedCard.ImmortalizeFrom</span>
</div>
}
</div>
</div>
</div>
}
@code {
[Parameter]
public string Name { get; set; } = "";
private DeckData? deck;
private CardData? selectedCard;
protected override void OnParametersSet()
{
var decoded = Uri.UnescapeDataString(Name);
deck = DeckDatabase.Decks.FirstOrDefault(d => d.IsVisible && d.Name == decoded);
}
private CardData? LookupCard(string cardName)
{
return CardDatabase.Cards.FirstOrDefault(c =>
string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase));
}
private void SelectCard(CardData? card)
{
selectedCard = card;
}
private void CloseDetail()
{
selectedCard = null;
}
}
+390
View File
@@ -0,0 +1,390 @@
.deck-detail-page {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem 3rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.85rem;
margin-bottom: 1.5rem;
transition: color var(--transition);
}
.back-link:hover {
color: var(--accent);
}
.deck-article {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
}
.deck-header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border);
}
.deck-header h1 {
margin: 0 0 0.5rem;
}
.deck-factions {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.faction-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.7rem;
border-radius: 999px;
background: var(--bg-elevated);
color: var(--accent);
border: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.deck-card-count {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0;
}
.deck-section {
margin-bottom: 2rem;
}
.deck-section:last-child {
margin-bottom: 0;
}
.deck-section h2 {
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
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: 110px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
transition: border-color var(--transition), transform var(--transition);
}
.mini-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
}
.mini-card-img {
width: 110px;
height: 100px;
overflow: hidden;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: center;
}
.mini-card-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.mini-card-name {
font-size: 0.75rem;
font-weight: 500;
padding: 0.35rem 0.4rem;
text-align: center;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.deck-description {
font-size: 0.95rem;
line-height: 1.7;
color: var(--text-secondary);
}
.deck-description p {
margin: 0 0 1rem;
}
.deck-description p:last-child {
margin-bottom: 0;
}
.empty-state {
text-align: center;
padding: 4rem 1rem;
color: var(--text-muted);
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
/* ── Detail Modal (shared with Cards page) ── */
.modal-backdrop {
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);
}
.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 {
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.category.token {
background: rgba(255, 213, 79, 0.15);
color: #ffd54f;
border-color: rgba(255, 213, 79, 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;
}
@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 {
max-height: 90vh;
}
}
+69
View File
@@ -0,0 +1,69 @@
@page "/decks"
<PageTitle>Decks</PageTitle>
<div class="decks-page">
<div class="decks-header">
<h1>Decks</h1>
<p class="text-secondary">@decks.Count deck@(decks.Count != 1 ? "s" : "")</p>
</div>
<div class="deck-list">
@foreach (var deck in decks)
{
<a class="deck-card" href="/decks/@Uri.EscapeDataString(deck.Name)">
<div class="deck-card-body">
<h2 class="deck-card-title">@deck.Name</h2>
@if (deck.Factions.Count > 0)
{
<div class="deck-card-factions">
@foreach (var f in deck.Factions)
{
<span class="faction-badge">@f</span>
}
</div>
}
<div class="deck-card-meta">
<span>@deck.Cards.Count card@(deck.Cards.Count != 1 ? "s" : "")</span>
@if (deck.Keycards.Count > 0)
{
<span>@deck.Keycards.Count keycard@(deck.Keycards.Count != 1 ? "s" : "")</span>
}
@if (deck.Divers.Count > 0)
{
<span>@deck.Divers.Count diver@(deck.Divers.Count != 1 ? "s" : "")</span>
}
</div>
</div>
@if (deck.Description != null)
{
<div class="deck-card-excerpt">@Truncate(deck.Description, 120)</div>
}
</a>
}
</div>
@if (decks.Count == 0)
{
<div class="empty-state">
<i class="bi bi-journal-text"></i>
<p>No decks available yet.</p>
</div>
}
</div>
@code {
private List<DeckData> decks = [];
protected override void OnInitialized()
{
decks = DeckDatabase.Decks.Where(d => d.IsVisible).ToList();
}
private static string Truncate(string text, int maxLength)
{
if (text.Length <= maxLength) return text;
var lastSpace = text.LastIndexOf(' ', maxLength);
return text[..(lastSpace > 0 ? lastSpace : maxLength)] + "...";
}
}
+93
View File
@@ -0,0 +1,93 @@
.decks-page {
max-width: 900px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.decks-header {
margin-bottom: 2rem;
}
.decks-header h1 {
margin: 0 0 0.25rem;
}
.deck-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.deck-card {
display: block;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
text-decoration: none;
color: inherit;
transition: border-color var(--transition), box-shadow var(--transition);
}
.deck-card:hover {
border-color: var(--accent);
box-shadow: 0 0 20px var(--accent-glow);
}
.deck-card-title {
font-size: 1.2rem;
font-weight: 600;
margin: 0 0 0.5rem;
color: var(--text-primary);
}
.deck-card-factions {
display: flex;
gap: 0.4rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.faction-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: var(--bg-elevated);
color: var(--accent);
border: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.deck-card-meta {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.deck-card-meta span + span::before {
content: "·";
margin-right: 0.75rem;
}
.deck-card-excerpt {
margin-top: 0.6rem;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.5;
}
.empty-state {
text-align: center;
padding: 4rem 1rem;
color: var(--text-muted);
}
.empty-state i {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}