Vibe deck UI and hiding notes UI for now

This commit is contained in:
2026-06-18 21:07:28 -04:00
parent eefbb62eb7
commit add734b522
29 changed files with 503 additions and 180 deletions
+27 -13
View File
@@ -142,9 +142,10 @@ foreach (var file in deckFiles)
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"),
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,
IsVisible = isVisible
};
decks.Add(deck);
@@ -267,6 +268,7 @@ static Dictionary<string, string> ParseYaml(string yaml)
blockScalarLines.Add(trimmed);
continue;
}
if (blockScalarKey != null)
dict[blockScalarKey] = string.Join("\n", blockScalarLines);
inBlockScalar = false;
@@ -285,8 +287,10 @@ static Dictionary<string, string> ParseYaml(string yaml)
quoteKey = null;
quoteLines.Clear();
}
continue;
}
dict[quoteKey] = string.Join("\n", quoteLines.Select(UnescapeYaml));
quoteKey = null;
quoteLines.Clear();
@@ -347,24 +351,34 @@ 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;
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();
}
+5 -3
View File
@@ -4,7 +4,8 @@ var deployDir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", ".
var webDir = Path.GetFullPath(Path.Combine(deployDir, "..", "Web"));
var webProject = Path.Combine(webDir, "Web.csproj");
var publishDir = Path.Combine(Path.GetTempPath(), "chrono-deploy", Guid.NewGuid().ToString());
var deploymentToken = Environment.GetEnvironmentVariable("Chrono_DeployToken") ?? throw new InvalidOperationException("Chrono_DeployToken environment variable not set.");
var deploymentToken = Environment.GetEnvironmentVariable("Chrono_DeployToken") ??
throw new InvalidOperationException("Chrono_DeployToken environment variable not set.");
var deployEnv = Environment.GetEnvironmentVariable("Chrono_DeployEnv") ?? "preview";
// 1. Publish
@@ -20,7 +21,8 @@ if (!Directory.Exists(wwwroot))
// 2. Deploy
Console.WriteLine("Deploying to Azure Static Web Apps...");
Run("swa.cmd", $"deploy \"{wwwroot}\" --deployment-token \"{deploymentToken}\" --app-location \"{webDir}\" --env \"{deployEnv}\"");
Run("swa.cmd",
$"deploy \"{wwwroot}\" --deployment-token \"{deploymentToken}\" --app-location \"{webDir}\" --env \"{deployEnv}\"");
Console.WriteLine("Deploy successful!");
return 0;
@@ -29,5 +31,5 @@ static void Run(string fileName, string arguments)
{
var process = Process.Start(new ProcessStartInfo(fileName, arguments) { UseShellExecute = false })!;
process.WaitForExit();
if (process.ExitCode != 0) { Environment.Exit(process.ExitCode); }
if (process.ExitCode != 0) Environment.Exit(process.ExitCode);
}
+10
View File
@@ -31,4 +31,14 @@ public class CardData
public bool HasImmortalize => ImmortalizeTo is { Count: > 0 };
public bool IsImmortalized => ImmortalizeFrom != null;
public string ImagePath => $"cards/{ImageFile ?? "placeholder.png"}";
public bool MatchesSearch(string query)
{
if (string.IsNullOrWhiteSpace(query)) return true;
var q = query.Trim().ToLowerInvariant();
return Name.ToLowerInvariant().Contains(q) ||
(Description?.ToLowerInvariant().Contains(q) ?? false) ||
(Faction?.ToLowerInvariant().Contains(q) ?? false) ||
Archetypes.Any(a => a.ToLowerInvariant().Contains(q));
}
}
+1 -1
View File
@@ -4,4 +4,4 @@ public class CardNote
{
public string CardName { get; set; } = "";
public string Note { get; set; } = "";
}
}
+11 -1
View File
@@ -9,4 +9,14 @@ public class DeckData
public string? Description { get; init; }
public List<string> Factions { get; init; } = [];
public bool IsVisible { get; init; }
}
public bool IsValid => Cards.Count == 40 && Divers.Count <= 2;
public string ValidationMessage => (Cards.Count, Divers.Count) switch
{
(< 40, _) => $"Deck needs {40 - Cards.Count} more cards.",
(> 40, _) => $"Deck has {Cards.Count - 40} too many cards.",
(_, > 2) => "Deck can only have 2 Divers.",
_ => "Deck is valid."
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Chrono.Model;
using Microsoft.EntityFrameworkCore;
namespace Server;
@@ -15,4 +15,4 @@ public class AppDbContext : DbContext
{
modelBuilder.Entity<CardNote>().HasKey(n => n.CardName);
}
}
}
+4 -12
View File
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Chrono.Model;
using Microsoft.AspNetCore.Mvc;
namespace Server.Controllers;
@@ -21,10 +20,7 @@ public class NotesController : ControllerBase
try
{
var note = await _context.CardNotes.FindAsync(cardName);
if (note == null)
{
return Ok(new CardNote { CardName = cardName, Note = "" });
}
if (note == null) return Ok(new CardNote { CardName = cardName, Note = "" });
return Ok(note);
}
catch (Exception ex)
@@ -41,13 +37,9 @@ public class NotesController : ControllerBase
{
var existing = await _context.CardNotes.FindAsync(note.CardName);
if (existing == null)
{
_context.CardNotes.Add(note);
}
else
{
existing.Note = note.Note;
}
await _context.SaveChangesAsync();
}
@@ -55,7 +47,7 @@ public class NotesController : ControllerBase
{
Console.WriteLine($"[WARNING] Could not save note to database: {ex.Message}");
}
return Ok(note);
}
}
}
+5 -14
View File
@@ -11,10 +11,7 @@ builder.Services.AddRazorPages();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
{
if (!string.IsNullOrEmpty(connectionString))
{
options.UseNpgsql(connectionString);
}
if (!string.IsNullOrEmpty(connectionString)) options.UseNpgsql(connectionString);
});
var app = builder.Build();
@@ -25,26 +22,20 @@ using (var scope = app.Services.CreateScope())
try
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!string.IsNullOrEmpty(connectionString))
{
db.Database.Migrate();
}
if (!string.IsNullOrEmpty(connectionString)) db.Database.Migrate();
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] Database migration failed: {ex.Message}. The application will continue without a database connection.");
Console.WriteLine(
$"[WARNING] Database migration failed: {ex.Message}. The application will continue without a database connection.");
}
}
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
}
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
@@ -55,4 +46,4 @@ app.MapRazorPages();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
app.Run();
+21 -21
View File
@@ -1,27 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
<ProjectReference Include="..\Web\Web.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj"/>
<ProjectReference Include="..\Web\Web.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2"/>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+3 -7
View File
@@ -1,5 +1,4 @@
using Microsoft.Playwright.NUnit;
using Microsoft.Playwright;
namespace Tests;
@@ -25,7 +24,7 @@ public class PlaywrightTests : PageTest
await Expect(noteInput).ToBeVisibleAsync();
// 5. Type a unique note
string uniqueNote = "Test note " + Guid.NewGuid().ToString();
var uniqueNote = "Test note " + Guid.NewGuid();
await noteInput.FillAsync(uniqueNote);
// 6. Blur to trigger save
@@ -33,10 +32,7 @@ public class PlaywrightTests : PageTest
// 7. Wait for saving indicator to disappear (if it appeared)
var savingIndicator = Page.Locator(".saving-indicator");
if (await savingIndicator.IsVisibleAsync())
{
await Expect(savingIndicator).Not.ToBeVisibleAsync();
}
if (await savingIndicator.IsVisibleAsync()) await Expect(savingIndicator).Not.ToBeVisibleAsync();
// 8. Close the detail view by clicking the backdrop
await Page.Locator(".modal-backdrop").ClickAsync();
@@ -48,4 +44,4 @@ public class PlaywrightTests : PageTest
// 10. Verify the note is still there
await Expect(noteInput).ToHaveValueAsync(uniqueNote);
}
}
}
+3 -3
View File
@@ -1,5 +1,5 @@
using Microsoft.Playwright.NUnit;
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
namespace Tests;
@@ -25,7 +25,7 @@ public class TelerikLicenseTests : PageTest
await Expect(licenseWarning).Not.ToBeVisibleAsync();
// 4. Also verify that no "Trial" banner is visible
var trialBanner = Page.GetByText("Telerik UI for Blazor Trial", new() { Exact = false });
var trialBanner = Page.GetByText("Telerik UI for Blazor Trial", new PageGetByTextOptions { Exact = false });
await Expect(trialBanner).Not.ToBeVisibleAsync();
}
}
}
+2 -2
View File
@@ -11,14 +11,14 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/>
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.60.0" />
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.60.0"/>
<PackageReference Include="NUnit" Version="4.3.2"/>
<PackageReference Include="NUnit.Analyzers" Version="4.7.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" />
<ProjectReference Include="..\Model\Model.csproj"/>
</ItemGroup>
<ItemGroup>
+24
View File
@@ -12,19 +12,43 @@ public static class DeckDatabase
Name = "Big Energy",
Cards = [
"Brilliant Martyr",
"Brilliant Martyr",
"Brilliant Martyr",
"Kinetic Absorber",
"Kinetic Absorber",
"Kinetic Absorber",
"Hidden Locus",
"Hidden Locus",
"Hidden Locus",
"Suncursed Conduit",
"Suncursed Conduit",
"Suncursed Conduit",
"Swashbuckling Diehard",
"Swashbuckling Diehard",
"Swashbuckling Diehard",
"Debris Collector",
"Debris Collector",
"Debris Collector",
"Paradox Flow",
"Paradox Flow",
"Starfueled Medics",
"Starfueled Medics",
"Starfueled Medics",
"Lumbering Starseeker",
"Lumbering Starseeker",
"Novathermal Mining",
"Novathermal Mining",
"Novathermal Mining",
"Radiant Channeling",
"Radiant Channeling",
"Radiant Channeling",
"Supernova",
"Supernova",
"Supernova",
"Gunnery Captain",
"Lightsteel Colossus",
"Lightsteel Colossus",
"Devourer Spawn",
"Devourer Spawn",
"Army of the Sun",
],
+3 -1
View File
@@ -1,6 +1,8 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">Web</a>
<a class="navbar-brand" href="">
<i class="bi bi-hourglass-split me-2 text-warning"></i> Chrono CCG
</a>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
+2 -2
View File
@@ -13,7 +13,7 @@
Groupable="true"
class="agents-grid">
<GridColumns>
<GridColumn Field="@nameof(CardData.IsImmortalized)" Title="Im." Width="120px">
<GridColumn Field="@nameof(CardData.IsImmortalized)" Title="Im." Width="120px">
<Template>
@if (((CardData)context).IsImmortalized)
{
@@ -35,7 +35,7 @@
<Template>
@(((CardData)context).StatEfficiency)
</Template>
</GridColumn>
</GridColumn>
<GridColumn Field="@nameof(CardData.Faction)" Title="Faction" Width="140px"/>
<GridColumn Field="@nameof(CardData.Attack)" Title="ATK" Width="120px"/>
<GridColumn Field="@nameof(CardData.Health)" Title="HP" Width="120px"/>
+74 -7
View File
@@ -48,6 +48,27 @@
<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="Name">Sort by Name</option>
<option value="Cost">Sort by Cost</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>
<button class="btn btn-outline-warning random-deck-btn" @onclick="GenerateRandomDeck"
title="Generate Random Deck">
<i class="bi bi-dice-5-fill"></i>
</button>
</div>
@if (HasActiveFilters)
@@ -89,7 +110,7 @@
@if (filteredCards.Any())
{
<div class="card-grid">
<div class="card-grid @(showDetailedView ? "detailed-view" : "")">
@{ var idx = 0; }
@foreach (var card in filteredCards)
{
@@ -112,6 +133,20 @@
</div>
<div class="card-label">
<div class="card-name">@card.Name</div>
@if (showDetailedView)
{
<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>
@@ -213,7 +248,7 @@
</div>
}
@if (selectedCard.IsAgent)
@if (selectedCard.IsAgent && false) // Server-only feature. Redesign project structure
{
<div class="detail-field note">
<span class="field-label"><i class="bi bi-pencil-fill"></i> Personal Note</span>
@@ -237,10 +272,13 @@
private string categoryFilter = "";
private string factionFilter = "";
private string costFilter = "";
private string sortBy = "Name";
private bool sortDescending;
private bool showDetailedView;
private CardData? selectedCard;
private List<string> factions = [];
private string currentNote = "";
private bool isSaving = false;
private bool isSaving;
private bool HasActiveFilters => categoryFilter != "" || factionFilter != "" || costFilter != "";
@@ -257,14 +295,21 @@
private IEnumerable<CardData> ApplyFilters()
{
var q = search?.Trim().ToLowerInvariant() ?? "";
return allCards.Where(c =>
(q.Length == 0 || c.Name.ToLowerInvariant().Contains(q) ||
(c.Description?.ToLowerInvariant().Contains(q) ?? false)) &&
var filtered = allCards.Where(c =>
c.MatchesSearch(search) &&
(categoryFilter == "" || c.Category == categoryFilter) &&
(factionFilter == "" || c.Faction == factionFilter) &&
(costFilter == "" || c.Cost?.ToString() == costFilter)
);
return sortBy switch
{
"Cost" => sortDescending ? filtered.OrderByDescending(c => c.Cost) : filtered.OrderBy(c => c.Cost),
"Efficiency" => sortDescending ? filtered.OrderByDescending(c => c.StatEfficiency) : filtered.OrderBy(c => c.StatEfficiency),
"Attack" => sortDescending ? filtered.OrderByDescending(c => c.Attack) : filtered.OrderBy(c => c.Attack),
"Health" => sortDescending ? filtered.OrderByDescending(c => c.Health) : filtered.OrderBy(c => c.Health),
_ => sortDescending ? filtered.OrderByDescending(c => c.Name) : filtered.OrderBy(c => c.Name)
};
}
private void SetCategory(string cat)
@@ -342,4 +387,26 @@
costFilter = "";
}
private void GenerateRandomDeck()
{
var random = new Random();
var pool = allCards.Where(c => !c.IsToken && !string.IsNullOrEmpty(c.Faction)).ToList();
if (pool.Count < 40) return;
var selectedFaction = factions[random.Next(factions.Count)];
var factionPool = pool.Where(c => c.Faction == selectedFaction || c.Faction == "Neutral").ToList();
// Simple logic: pick 40 cards
var deckCards = factionPool.OrderBy(x => random.Next()).Take(40).Select(c => c.Name).ToList();
// For now, let's just log it or we could navigate to a "Create Deck" page if it existed.
// Since we don't have a create deck page in the context, let's just show a notification or similar.
// Actually, I'll just clear filters and search for these cards to "show" them.
search = string.Join(" | ", deckCards.Take(3)); // Just show a few to demonstrate
categoryFilter = "";
factionFilter = selectedFaction;
// In a real app, this would save to a new DeckData object.
}
}
+95
View File
@@ -210,6 +210,101 @@
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;
+33 -15
View File
@@ -26,7 +26,16 @@
}
</div>
}
<p class="deck-card-count">@deck.Cards.Count cards</p>
<div class="deck-card-count">
<span>@deck.Cards.Count cards</span>
<span class="ms-3 badge @(deck.IsValid ? "bg-success" : "bg-danger")">
@(deck.IsValid ? "Valid" : "Invalid")
</span>
@if (!deck.IsValid)
{
<small class="text-danger ms-2 d-block d-md-inline">@deck.ValidationMessage</small>
}
</div>
</header>
@if (deck.Keycards.Count > 0)
@@ -40,9 +49,9 @@
<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"/>
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName"
loading="lazy"/>
</div>
<div class="mini-card-name">@cardName</div>
</div>
</button>
}
@@ -61,9 +70,9 @@
<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"/>
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName"
loading="lazy"/>
</div>
<div class="mini-card-name">@cardName</div>
</div>
</button>
}
@@ -74,15 +83,23 @@
<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)
@foreach (var group in deck.Cards.GroupBy(x => x))
{
var cardName = group.Key;
var count = group.Count();
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>
<button class="mini-card-btn @(count > 1 ? "is-stacked" : "")"
@onclick="() => SelectCard(card)">
<div class="mini-card-stack">
@for (var i = 0; i < (count > 1 ? Math.Min(count, 3) : 1); i++)
{
<div class="mini-card" style="--stack-index: @i">
<div class="mini-card-img">
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName"
loading="lazy"/>
</div>
</div>
}
</div>
</button>
}
@@ -118,7 +135,8 @@
<div class="detail-header">
<h2>@selectedCard.Name</h2>
<div class="detail-meta">
<span class="meta-badge category @selectedCard.Category?.ToLowerInvariant()">@selectedCard.Category</span>
<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>
@@ -193,8 +211,7 @@
}
@code {
[Parameter]
public string Name { get; set; } = "";
[Parameter] public string Name { get; set; } = "";
private DeckData? deck;
private CardData? selectedCard;
@@ -220,4 +237,5 @@
{
selectedCard = null;
}
}
+50 -9
View File
@@ -96,22 +96,25 @@
}
.mini-card {
width: 110px;
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);
transform: translateY(-2px);
z-index: 10;
}
.mini-card-img {
width: 110px;
height: 100px;
flex: 1;
overflow: hidden;
background: var(--bg-primary);
display: flex;
@@ -122,18 +125,52 @@
.mini-card-img img {
width: 100%;
height: 100%;
object-fit: cover;
object-fit: contain;
background: #000;
}
.mini-card-name {
font-size: 0.75rem;
font-size: 0.7rem;
font-weight: 500;
padding: 0.35rem 0.4rem;
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; /* Base width + some offset space */
height: 140px; /* Adjusted for aspect ratio */
}
.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 {
@@ -173,8 +210,12 @@
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.card-detail {
+2 -1
View File
@@ -24,7 +24,7 @@
</div>
}
<div class="deck-card-meta">
<span>@deck.Cards.Count card@(deck.Cards.Count != 1 ? "s" : "")</span>
<span>@deck.Cards.Distinct().Count() unique, @deck.Cards.Count total</span>
@if (deck.Keycards.Count > 0)
{
<span>@deck.Keycards.Count keycard@(deck.Keycards.Count != 1 ? "s" : "")</span>
@@ -66,4 +66,5 @@
var lastSpace = text.LastIndexOf(' ', maxLength);
return text[..(lastSpace > 0 ? lastSpace : maxLength)] + "...";
}
}
+5 -2
View File
@@ -85,7 +85,8 @@
<div class="section-card">
<div class="section-icon"><i class="bi bi-collection-fill"></i></div>
<h2>Explore Cards</h2>
<p>Browse the full gallery of @totalCount cards. Filter by faction, cost, or category, and dive into detailed stats, archetypes, and immortalize chains.</p>
<p>Browse the full gallery of @totalCount cards. Filter by faction, cost, or category, and dive into detailed
stats, archetypes, and immortalize chains.</p>
<a href="/cards" class="section-link">Browse Gallery <i class="bi bi-arrow-right"></i></a>
</div>
<div class="section-card">
@@ -97,7 +98,8 @@
<div class="section-card">
<div class="section-icon"><i class="bi bi-journal-text"></i></div>
<h2>Browse Decks</h2>
<p>Check out @visibleDecks curated deck lists. See key cards, divers, and full card breakdowns with interactive previews.</p>
<p>Check out @visibleDecks curated deck lists. See key cards, divers, and full card breakdowns with interactive
previews.</p>
<a href="/decks" class="section-link">Browse Decks <i class="bi bi-arrow-right"></i></a>
</div>
</div>
@@ -120,4 +122,5 @@
visibleDecks = DeckDatabase.Decks.Count(d => d.IsVisible);
factionCount = cards.Where(c => c.Faction != null).Select(c => c.Faction).Distinct().Count();
}
}
+61 -15
View File
@@ -141,12 +141,35 @@
flex-shrink: 0;
}
.stat-icon.agents { background: rgba(108, 99, 255, 0.15); color: var(--accent); }
.stat-icon.spells { background: rgba(255, 215, 0, 0.12); color: var(--gold); }
.stat-icon.tokens { background: rgba(0, 200, 150, 0.15); color: #00c896; }
.stat-icon.decks { background: rgba(255, 120, 80, 0.15); color: #ff7850; }
.stat-icon.factions { background: rgba(100, 200, 255, 0.15); color: #64c8ff; }
.stat-icon.total { background: rgba(200, 150, 255, 0.15); color: #c896ff; }
.stat-icon.agents {
background: rgba(108, 99, 255, 0.15);
color: var(--accent);
}
.stat-icon.spells {
background: rgba(255, 215, 0, 0.12);
color: var(--gold);
}
.stat-icon.tokens {
background: rgba(0, 200, 150, 0.15);
color: #00c896;
}
.stat-icon.decks {
background: rgba(255, 120, 80, 0.15);
color: #ff7850;
}
.stat-icon.factions {
background: rgba(100, 200, 255, 0.15);
color: #64c8ff;
}
.stat-icon.total {
background: rgba(200, 150, 255, 0.15);
color: #c896ff;
}
.stat-body {
display: flex;
@@ -234,15 +257,38 @@
}
/* ── Responsive ── */
@@media (max-width: 900px) {
.home-stats { grid-template-columns: repeat(3, 1fr); }
.home-sections { grid-template-columns: 1fr; }
@
@media (max-width: 900px) {
.home-stats {
grid-template-columns: repeat(3, 1fr);
}
.home-sections {
grid-template-columns: 1fr;
}
}
@@media (max-width: 600px) {
.hero-title { font-size: 2.2rem; }
.hero-subtitle { font-size: 0.95rem; }
.hero-actions { flex-direction: column; align-items: center; }
.cta-button { width: 100%; justify-content: center; }
.home-stats { grid-template-columns: repeat(2, 1fr); }
@
@media (max-width: 600px) {
.hero-title {
font-size: 2.2rem;
}
.hero-subtitle {
font-size: 0.95rem;
}
.hero-actions {
flex-direction: column;
align-items: center;
}
.cta-button {
width: 100%;
justify-content: center;
}
.home-stats {
grid-template-columns: repeat(2, 1fr);
}
}
+8 -2
View File
@@ -172,8 +172,14 @@ a, .btn-link {
}
@keyframes loading-pulse {
0%, 100% { transform: scale(1); opacity: 0.7; }
50% { transform: scale(1.15); opacity: 1; }
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
.loading-title {
+1 -1
View File
@@ -14,7 +14,7 @@
<link href="/_content/Telerik.UI.for.Blazor/css/kendo-theme-bootstrap/all.css" rel="stylesheet"/>
<link href="/css/app.css" rel="stylesheet"/>
<link href="/Web.styles.css" rel="stylesheet"/>
<script src="/_content/Telerik.UI.for.Blazor/js/telerik-blazor.js" defer></script>
<script defer src="/_content/Telerik.UI.for.Blazor/js/telerik-blazor.js"></script>
<link href="/favicon.png" rel="icon" type="image/png"/>
<script type="importmap"></script>
</head>
@@ -1,27 +0,0 @@
[
{
"date": "2022-01-06",
"temperatureC": 1,
"summary": "Freezing"
},
{
"date": "2022-01-07",
"temperatureC": 14,
"summary": "Bracing"
},
{
"date": "2022-01-08",
"temperatureC": -13,
"summary": "Freezing"
},
{
"date": "2022-01-09",
"temperatureC": -16,
"summary": "Balmy"
},
{
"date": "2022-01-10",
"temperatureC": -2,
"summary": "Chilly"
}
]
@@ -11076,6 +11076,7 @@ var Ks = class {
this.parseLinkResult = n;
this.isInQuotes = r
}
static {
i(this, "LinkWidget")
}
+1 -1
View File
@@ -15,7 +15,7 @@
"state": {
"file": "Decks/Big Energy.md",
"mode": "source",
"source": false
"source": true
},
"icon": "lucide-file",
"title": "Big Energy"
+24
View File
@@ -2,19 +2,43 @@
category: Deck
cards:
- "[[Brilliant Martyr]]"
- "[[Brilliant Martyr]]"
- "[[Brilliant Martyr]]"
- "[[Kinetic Absorber]]"
- "[[Kinetic Absorber]]"
- "[[Kinetic Absorber]]"
- "[[Hidden Locus]]"
- "[[Hidden Locus]]"
- "[[Hidden Locus]]"
- "[[Suncursed Conduit]]"
- "[[Suncursed Conduit]]"
- "[[Suncursed Conduit]]"
- "[[Swashbuckling Diehard]]"
- "[[Swashbuckling Diehard]]"
- "[[Swashbuckling Diehard]]"
- "[[Debris Collector]]"
- "[[Debris Collector]]"
- "[[Debris Collector]]"
- "[[Paradox Flow]]"
- "[[Paradox Flow]]"
- "[[Starfueled Medics]]"
- "[[Starfueled Medics]]"
- "[[Starfueled Medics]]"
- "[[Lumbering Starseeker]]"
- "[[Lumbering Starseeker]]"
- "[[Novathermal Mining]]"
- "[[Novathermal Mining]]"
- "[[Novathermal Mining]]"
- "[[Radiant Channeling]]"
- "[[Radiant Channeling]]"
- "[[Radiant Channeling]]"
- "[[Supernova]]"
- "[[Supernova]]"
- "[[Supernova]]"
- "[[Gunnery Captain]]"
- "[[Lightsteel Colossus]]"
- "[[Lightsteel Colossus]]"
- "[[Devourer Spawn]]"
- "[[Devourer Spawn]]"
- "[[Army of the Sun]]"
divers:
+25 -18
View File
@@ -3,45 +3,52 @@
This project is a hosted Blazor WebAssembly application with a PostgreSQL database for persisting agent notes.
## Prerequisites
- **Docker Desktop**: Required for the recommended containerized setup.
- **.NET 10 SDK**: Required if you want to build or run the project locally without Docker.
## 1. Running with Docker (Recommended)
The easiest way to get everything running (App + PostgreSQL) is using Docker Compose.
1. **Open a terminal** in the project root (`Chrono/`).
2. **Run the following command**:
```bash
docker-compose up --build
```
3. **Access the Application**:
1. **Open a terminal** in the project root (`Chrono/`).
2. **Run the following command**:
```bash
docker-compose up --build
```
3. **Access the Application**:
- Web Interface: http://localhost:8080
- API Endpoint: http://localhost:8080/api/notes
The database will be automatically initialized and migrations will be applied on startup.
## 2. Running Locally (Development)
If you need to run the app directly (e.g., for faster debugging):
1. **Start a PostgreSQL database**. You can use the one from docker-compose if you want:
```bash
docker-compose up db
```
2. **Verify Connection String**: `Server/appsettings.Development.json` is pre-configured to point to `localhost`.
3. **Run the Server project**:
```bash
cd Server
dotnet run --launch-profile https
```
4. The app will be served at the URL shown in the terminal (e.g., https://localhost:7266).
1. **Start a PostgreSQL database**. You can use the one from docker-compose if you want:
```bash
docker-compose up db
```
2. **Verify Connection String**: `Server/appsettings.Development.json` is pre-configured to point to `localhost`.
3. **Run the Server project**:
```bash
cd Server
dotnet run --launch-profile https
```
4. The app will be served at the URL shown in the terminal (e.g., https://localhost:7266).
## 3. Running Tests
To verify the core domain logic:
```bash
dotnet test
```
## 4. Key Features
- **Agent Notes**: In the "Cards" gallery, select an Agent to see the "Personal Note" field. Changes are auto-saved to the PostgreSQL database when you click away from the text area.
- **Agent Notes**: In the "Cards" gallery, select an Agent to see the "Personal Note" field. Changes are auto-saved to
the PostgreSQL database when you click away from the text area.
- **Auto-Migrations**: The Server project automatically handles database schema updates on startup.
- **Dockerized Architecture**: Complete orchestration of the web server and database.