This commit is contained in:
2026-06-18 21:07:28 -04:00
parent eefbb62eb7
commit 50dcc8e55c
29 changed files with 502 additions and 179 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(), Cards = ParseList(yaml, "cards").Select(s => StripWikiLink(s) ?? "").Where(s => s != "").ToList(),
Keycards = ParseList(yaml, "keycards").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(), 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(), Factions = ParseList(yaml, "factions").Select(s => StripWikiLink(s) ?? "").Where(s => s != "").ToList(),
IsVisible = isVisible, IsVisible = isVisible
}; };
decks.Add(deck); decks.Add(deck);
@@ -267,6 +268,7 @@ static Dictionary<string, string> ParseYaml(string yaml)
blockScalarLines.Add(trimmed); blockScalarLines.Add(trimmed);
continue; continue;
} }
if (blockScalarKey != null) if (blockScalarKey != null)
dict[blockScalarKey] = string.Join("\n", blockScalarLines); dict[blockScalarKey] = string.Join("\n", blockScalarLines);
inBlockScalar = false; inBlockScalar = false;
@@ -285,8 +287,10 @@ static Dictionary<string, string> ParseYaml(string yaml)
quoteKey = null; quoteKey = null;
quoteLines.Clear(); quoteLines.Clear();
} }
continue; continue;
} }
dict[quoteKey] = string.Join("\n", quoteLines.Select(UnescapeYaml)); dict[quoteKey] = string.Join("\n", quoteLines.Select(UnescapeYaml));
quoteKey = null; quoteKey = null;
quoteLines.Clear(); quoteLines.Clear();
@@ -347,24 +351,34 @@ static string UnescapeYaml(string s)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
for (var i = 0; i < s.Length; i++) for (var i = 0; i < s.Length; i++)
{
if (s[i] == '\\' && i + 1 < s.Length) if (s[i] == '\\' && i + 1 < s.Length)
{
switch (s[i + 1]) switch (s[i + 1])
{ {
case 'r': sb.Append('\r'); i++; break; case 'r':
case 'n': sb.Append('\n'); i++; break; sb.Append('\r');
case 't': sb.Append('\t'); i++; break; i++;
case '\\': sb.Append('\\'); i++; break; break;
case '"': sb.Append('"'); 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; default: sb.Append(s[i]); break;
} }
}
else else
{
sb.Append(s[i]); sb.Append(s[i]);
}
}
return sb.ToString(); 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 webDir = Path.GetFullPath(Path.Combine(deployDir, "..", "Web"));
var webProject = Path.Combine(webDir, "Web.csproj"); var webProject = Path.Combine(webDir, "Web.csproj");
var publishDir = Path.Combine(Path.GetTempPath(), "chrono-deploy", Guid.NewGuid().ToString()); 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"; var deployEnv = Environment.GetEnvironmentVariable("Chrono_DeployEnv") ?? "preview";
// 1. Publish // 1. Publish
@@ -20,7 +21,8 @@ if (!Directory.Exists(wwwroot))
// 2. Deploy // 2. Deploy
Console.WriteLine("Deploying to Azure Static Web Apps..."); 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!"); Console.WriteLine("Deploy successful!");
return 0; return 0;
@@ -29,5 +31,5 @@ static void Run(string fileName, string arguments)
{ {
var process = Process.Start(new ProcessStartInfo(fileName, arguments) { UseShellExecute = false })!; var process = Process.Start(new ProcessStartInfo(fileName, arguments) { UseShellExecute = false })!;
process.WaitForExit(); 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 HasImmortalize => ImmortalizeTo is { Count: > 0 };
public bool IsImmortalized => ImmortalizeFrom != null; public bool IsImmortalized => ImmortalizeFrom != null;
public string ImagePath => $"cards/{ImageFile ?? "placeholder.png"}"; 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 CardName { get; set; } = "";
public string Note { get; set; } = ""; public string Note { get; set; } = "";
} }
+11 -1
View File
@@ -9,4 +9,14 @@ public class DeckData
public string? Description { get; init; } public string? Description { get; init; }
public List<string> Factions { get; init; } = []; public List<string> Factions { get; init; } = [];
public bool IsVisible { 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 Chrono.Model;
using Microsoft.EntityFrameworkCore;
namespace Server; namespace Server;
@@ -15,4 +15,4 @@ public class AppDbContext : DbContext
{ {
modelBuilder.Entity<CardNote>().HasKey(n => n.CardName); 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 Chrono.Model;
using Microsoft.AspNetCore.Mvc;
namespace Server.Controllers; namespace Server.Controllers;
@@ -21,10 +20,7 @@ public class NotesController : ControllerBase
try try
{ {
var note = await _context.CardNotes.FindAsync(cardName); var note = await _context.CardNotes.FindAsync(cardName);
if (note == null) if (note == null) return Ok(new CardNote { CardName = cardName, Note = "" });
{
return Ok(new CardNote { CardName = cardName, Note = "" });
}
return Ok(note); return Ok(note);
} }
catch (Exception ex) catch (Exception ex)
@@ -41,13 +37,9 @@ public class NotesController : ControllerBase
{ {
var existing = await _context.CardNotes.FindAsync(note.CardName); var existing = await _context.CardNotes.FindAsync(note.CardName);
if (existing == null) if (existing == null)
{
_context.CardNotes.Add(note); _context.CardNotes.Add(note);
}
else else
{
existing.Note = note.Note; existing.Note = note.Note;
}
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
@@ -55,7 +47,7 @@ public class NotesController : ControllerBase
{ {
Console.WriteLine($"[WARNING] Could not save note to database: {ex.Message}"); Console.WriteLine($"[WARNING] Could not save note to database: {ex.Message}");
} }
return Ok(note); return Ok(note);
} }
} }
+5 -14
View File
@@ -11,10 +11,7 @@ builder.Services.AddRazorPages();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
{ {
if (!string.IsNullOrEmpty(connectionString)) if (!string.IsNullOrEmpty(connectionString)) options.UseNpgsql(connectionString);
{
options.UseNpgsql(connectionString);
}
}); });
var app = builder.Build(); var app = builder.Build();
@@ -25,26 +22,20 @@ using (var scope = app.Services.CreateScope())
try try
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
if (!string.IsNullOrEmpty(connectionString)) if (!string.IsNullOrEmpty(connectionString)) db.Database.Migrate();
{
db.Database.Migrate();
}
} }
catch (Exception ex) 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. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging(); app.UseWebAssemblyDebugging();
}
else else
{
app.UseExceptionHandler("/Error"); app.UseExceptionHandler("/Error");
}
app.UseBlazorFrameworkFiles(); app.UseBlazorFrameworkFiles();
app.UseStaticFiles(); app.UseStaticFiles();
@@ -55,4 +46,4 @@ app.MapRazorPages();
app.MapControllers(); app.MapControllers();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
app.Run(); app.Run();
+21 -21
View File
@@ -1,27 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" /> <ProjectReference Include="..\Model\Model.csproj"/>
<ProjectReference Include="..\Web\Web.csproj" /> <ProjectReference Include="..\Web\Web.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.9"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2"/>
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+3 -7
View File
@@ -1,5 +1,4 @@
using Microsoft.Playwright.NUnit; using Microsoft.Playwright.NUnit;
using Microsoft.Playwright;
namespace Tests; namespace Tests;
@@ -25,7 +24,7 @@ public class PlaywrightTests : PageTest
await Expect(noteInput).ToBeVisibleAsync(); await Expect(noteInput).ToBeVisibleAsync();
// 5. Type a unique note // 5. Type a unique note
string uniqueNote = "Test note " + Guid.NewGuid().ToString(); var uniqueNote = "Test note " + Guid.NewGuid();
await noteInput.FillAsync(uniqueNote); await noteInput.FillAsync(uniqueNote);
// 6. Blur to trigger save // 6. Blur to trigger save
@@ -33,10 +32,7 @@ public class PlaywrightTests : PageTest
// 7. Wait for saving indicator to disappear (if it appeared) // 7. Wait for saving indicator to disappear (if it appeared)
var savingIndicator = Page.Locator(".saving-indicator"); var savingIndicator = Page.Locator(".saving-indicator");
if (await savingIndicator.IsVisibleAsync()) if (await savingIndicator.IsVisibleAsync()) await Expect(savingIndicator).Not.ToBeVisibleAsync();
{
await Expect(savingIndicator).Not.ToBeVisibleAsync();
}
// 8. Close the detail view by clicking the backdrop // 8. Close the detail view by clicking the backdrop
await Page.Locator(".modal-backdrop").ClickAsync(); await Page.Locator(".modal-backdrop").ClickAsync();
@@ -48,4 +44,4 @@ public class PlaywrightTests : PageTest
// 10. Verify the note is still there // 10. Verify the note is still there
await Expect(noteInput).ToHaveValueAsync(uniqueNote); await Expect(noteInput).ToHaveValueAsync(uniqueNote);
} }
} }
+3 -3
View File
@@ -1,5 +1,5 @@
using Microsoft.Playwright.NUnit;
using Microsoft.Playwright; using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
namespace Tests; namespace Tests;
@@ -25,7 +25,7 @@ public class TelerikLicenseTests : PageTest
await Expect(licenseWarning).Not.ToBeVisibleAsync(); await Expect(licenseWarning).Not.ToBeVisibleAsync();
// 4. Also verify that no "Trial" banner is visible // 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(); await Expect(trialBanner).Not.ToBeVisibleAsync();
} }
} }
+2 -2
View File
@@ -11,14 +11,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/> <PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/> <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" Version="4.3.2"/>
<PackageReference Include="NUnit.Analyzers" Version="4.7.0"/> <PackageReference Include="NUnit.Analyzers" Version="4.7.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/> <PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Model\Model.csproj" /> <ProjectReference Include="..\Model\Model.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+24
View File
@@ -12,19 +12,43 @@ public static class DeckDatabase
Name = "Big Energy", Name = "Big Energy",
Cards = [ Cards = [
"Brilliant Martyr", "Brilliant Martyr",
"Brilliant Martyr",
"Brilliant Martyr",
"Kinetic Absorber",
"Kinetic Absorber",
"Kinetic Absorber", "Kinetic Absorber",
"Hidden Locus", "Hidden Locus",
"Hidden Locus",
"Hidden Locus",
"Suncursed Conduit",
"Suncursed Conduit",
"Suncursed Conduit", "Suncursed Conduit",
"Swashbuckling Diehard", "Swashbuckling Diehard",
"Swashbuckling Diehard",
"Swashbuckling Diehard",
"Debris Collector",
"Debris Collector",
"Debris Collector", "Debris Collector",
"Paradox Flow", "Paradox Flow",
"Paradox Flow",
"Starfueled Medics",
"Starfueled Medics",
"Starfueled Medics", "Starfueled Medics",
"Lumbering Starseeker", "Lumbering Starseeker",
"Lumbering Starseeker",
"Novathermal Mining",
"Novathermal Mining",
"Novathermal Mining", "Novathermal Mining",
"Radiant Channeling", "Radiant Channeling",
"Radiant Channeling",
"Radiant Channeling",
"Supernova",
"Supernova",
"Supernova", "Supernova",
"Gunnery Captain", "Gunnery Captain",
"Lightsteel Colossus", "Lightsteel Colossus",
"Lightsteel Colossus",
"Devourer Spawn",
"Devourer Spawn", "Devourer Spawn",
"Army of the Sun", "Army of the Sun",
], ],
+3 -1
View File
@@ -1,6 +1,8 @@
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <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"> <button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
+2 -2
View File
@@ -13,7 +13,7 @@
Groupable="true" Groupable="true"
class="agents-grid"> class="agents-grid">
<GridColumns> <GridColumns>
<GridColumn Field="@nameof(CardData.IsImmortalized)" Title="Im." Width="120px"> <GridColumn Field="@nameof(CardData.IsImmortalized)" Title="Im." Width="120px">
<Template> <Template>
@if (((CardData)context).IsImmortalized) @if (((CardData)context).IsImmortalized)
{ {
@@ -35,7 +35,7 @@
<Template> <Template>
@(((CardData)context).StatEfficiency) @(((CardData)context).StatEfficiency)
</Template> </Template>
</GridColumn> </GridColumn>
<GridColumn Field="@nameof(CardData.Faction)" Title="Faction" Width="140px"/> <GridColumn Field="@nameof(CardData.Faction)" Title="Faction" Width="140px"/>
<GridColumn Field="@nameof(CardData.Attack)" Title="ATK" Width="120px"/> <GridColumn Field="@nameof(CardData.Attack)" Title="ATK" Width="120px"/>
<GridColumn Field="@nameof(CardData.Health)" Title="HP" Width="120px"/> <GridColumn Field="@nameof(CardData.Health)" Title="HP" Width="120px"/>
+73 -6
View File
@@ -48,6 +48,27 @@
<option value="@i">@i</option> <option value="@i">@i</option>
} }
</select> </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> </div>
@if (HasActiveFilters) @if (HasActiveFilters)
@@ -89,7 +110,7 @@
@if (filteredCards.Any()) @if (filteredCards.Any())
{ {
<div class="card-grid"> <div class="card-grid @(showDetailedView ? "detailed-view" : "")">
@{ var idx = 0; } @{ var idx = 0; }
@foreach (var card in filteredCards) @foreach (var card in filteredCards)
{ {
@@ -112,6 +133,20 @@
</div> </div>
<div class="card-label"> <div class="card-label">
<div class="card-name">@card.Name</div> <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 class="card-category-badge @card.Category?.ToLowerInvariant()">@card.Category</div>
</div> </div>
</div> </div>
@@ -237,10 +272,13 @@
private string categoryFilter = ""; private string categoryFilter = "";
private string factionFilter = ""; private string factionFilter = "";
private string costFilter = ""; private string costFilter = "";
private string sortBy = "Name";
private bool sortDescending;
private bool showDetailedView;
private CardData? selectedCard; private CardData? selectedCard;
private List<string> factions = []; private List<string> factions = [];
private string currentNote = ""; private string currentNote = "";
private bool isSaving = false; private bool isSaving;
private bool HasActiveFilters => categoryFilter != "" || factionFilter != "" || costFilter != ""; private bool HasActiveFilters => categoryFilter != "" || factionFilter != "" || costFilter != "";
@@ -257,14 +295,21 @@
private IEnumerable<CardData> ApplyFilters() private IEnumerable<CardData> ApplyFilters()
{ {
var q = search?.Trim().ToLowerInvariant() ?? ""; var filtered = allCards.Where(c =>
return allCards.Where(c => c.MatchesSearch(search) &&
(q.Length == 0 || c.Name.ToLowerInvariant().Contains(q) ||
(c.Description?.ToLowerInvariant().Contains(q) ?? false)) &&
(categoryFilter == "" || c.Category == categoryFilter) && (categoryFilter == "" || c.Category == categoryFilter) &&
(factionFilter == "" || c.Faction == factionFilter) && (factionFilter == "" || c.Faction == factionFilter) &&
(costFilter == "" || c.Cost?.ToString() == costFilter) (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) private void SetCategory(string cat)
@@ -342,4 +387,26 @@
costFilter = ""; 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; 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 ── */
.card-cell { .card-cell {
cursor: pointer; cursor: pointer;
+33 -15
View File
@@ -26,7 +26,16 @@
} }
</div> </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> </header>
@if (deck.Keycards.Count > 0) @if (deck.Keycards.Count > 0)
@@ -40,9 +49,9 @@
<button class="mini-card-btn" @onclick="() => SelectCard(card)"> <button class="mini-card-btn" @onclick="() => SelectCard(card)">
<div class="mini-card"> <div class="mini-card">
<div class="mini-card-img"> <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>
<div class="mini-card-name">@cardName</div>
</div> </div>
</button> </button>
} }
@@ -61,9 +70,9 @@
<button class="mini-card-btn" @onclick="() => SelectCard(card)"> <button class="mini-card-btn" @onclick="() => SelectCard(card)">
<div class="mini-card"> <div class="mini-card">
<div class="mini-card-img"> <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>
<div class="mini-card-name">@cardName</div>
</div> </div>
</button> </button>
} }
@@ -74,15 +83,23 @@
<section class="deck-section"> <section class="deck-section">
<h2><i class="bi bi-collection-fill"></i> Cards</h2> <h2><i class="bi bi-collection-fill"></i> Cards</h2>
<div class="deck-card-row"> <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); var card = LookupCard(cardName);
<button class="mini-card-btn" @onclick="() => SelectCard(card)"> <button class="mini-card-btn @(count > 1 ? "is-stacked" : "")"
<div class="mini-card"> @onclick="() => SelectCard(card)">
<div class="mini-card-img"> <div class="mini-card-stack">
<img src="@(card?.ImagePath ?? "cards/placeholder.png")" alt="@cardName" loading="lazy"/> @for (var i = 0; i < (count > 1 ? Math.Min(count, 3) : 1); i++)
</div> {
<div class="mini-card-name">@cardName</div> <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> </div>
</button> </button>
} }
@@ -118,7 +135,8 @@
<div class="detail-header"> <div class="detail-header">
<h2>@selectedCard.Name</h2> <h2>@selectedCard.Name</h2>
<div class="detail-meta"> <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) @if (selectedCard.Cost.HasValue)
{ {
<span class="meta-badge cost"><i class="bi bi-lightning-fill"></i> @selectedCard.Cost</span> <span class="meta-badge cost"><i class="bi bi-lightning-fill"></i> @selectedCard.Cost</span>
@@ -193,8 +211,7 @@
} }
@code { @code {
[Parameter] [Parameter] public string Name { get; set; } = "";
public string Name { get; set; } = "";
private DeckData? deck; private DeckData? deck;
private CardData? selectedCard; private CardData? selectedCard;
@@ -220,4 +237,5 @@
{ {
selectedCard = null; selectedCard = null;
} }
} }
+50 -9
View File
@@ -96,22 +96,25 @@
} }
.mini-card { .mini-card {
width: 110px; width: 100px;
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
overflow: hidden; overflow: hidden;
transition: border-color var(--transition), transform var(--transition); transition: border-color var(--transition), transform var(--transition);
position: relative;
aspect-ratio: 2.5 / 3.5;
display: flex;
flex-direction: column;
} }
.mini-card:hover { .mini-card:hover {
border-color: var(--accent); border-color: var(--accent);
transform: translateY(-2px); z-index: 10;
} }
.mini-card-img { .mini-card-img {
width: 110px; flex: 1;
height: 100px;
overflow: hidden; overflow: hidden;
background: var(--bg-primary); background: var(--bg-primary);
display: flex; display: flex;
@@ -122,18 +125,52 @@
.mini-card-img img { .mini-card-img img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
background: #000;
} }
.mini-card-name { .mini-card-name {
font-size: 0.75rem; font-size: 0.7rem;
font-weight: 500; font-weight: 500;
padding: 0.35rem 0.4rem; padding: 0.25rem 0.3rem;
text-align: center; text-align: center;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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 { .deck-description {
@@ -173,8 +210,12 @@
} }
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
.card-detail { .card-detail {
+2 -1
View File
@@ -24,7 +24,7 @@
</div> </div>
} }
<div class="deck-card-meta"> <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) @if (deck.Keycards.Count > 0)
{ {
<span>@deck.Keycards.Count keycard@(deck.Keycards.Count != 1 ? "s" : "")</span> <span>@deck.Keycards.Count keycard@(deck.Keycards.Count != 1 ? "s" : "")</span>
@@ -66,4 +66,5 @@
var lastSpace = text.LastIndexOf(' ', maxLength); var lastSpace = text.LastIndexOf(' ', maxLength);
return text[..(lastSpace > 0 ? lastSpace : maxLength)] + "..."; return text[..(lastSpace > 0 ? lastSpace : maxLength)] + "...";
} }
} }
+5 -2
View File
@@ -85,7 +85,8 @@
<div class="section-card"> <div class="section-card">
<div class="section-icon"><i class="bi bi-collection-fill"></i></div> <div class="section-icon"><i class="bi bi-collection-fill"></i></div>
<h2>Explore Cards</h2> <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> <a href="/cards" class="section-link">Browse Gallery <i class="bi bi-arrow-right"></i></a>
</div> </div>
<div class="section-card"> <div class="section-card">
@@ -97,7 +98,8 @@
<div class="section-card"> <div class="section-card">
<div class="section-icon"><i class="bi bi-journal-text"></i></div> <div class="section-icon"><i class="bi bi-journal-text"></i></div>
<h2>Browse Decks</h2> <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> <a href="/decks" class="section-link">Browse Decks <i class="bi bi-arrow-right"></i></a>
</div> </div>
</div> </div>
@@ -120,4 +122,5 @@
visibleDecks = DeckDatabase.Decks.Count(d => d.IsVisible); visibleDecks = DeckDatabase.Decks.Count(d => d.IsVisible);
factionCount = cards.Where(c => c.Faction != null).Select(c => c.Faction).Distinct().Count(); factionCount = cards.Where(c => c.Faction != null).Select(c => c.Faction).Distinct().Count();
} }
} }
+61 -15
View File
@@ -141,12 +141,35 @@
flex-shrink: 0; flex-shrink: 0;
} }
.stat-icon.agents { background: rgba(108, 99, 255, 0.15); color: var(--accent); } .stat-icon.agents {
.stat-icon.spells { background: rgba(255, 215, 0, 0.12); color: var(--gold); } background: rgba(108, 99, 255, 0.15);
.stat-icon.tokens { background: rgba(0, 200, 150, 0.15); color: #00c896; } color: var(--accent);
.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.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 { .stat-body {
display: flex; display: flex;
@@ -234,15 +257,38 @@
} }
/* ── Responsive ── */ /* ── Responsive ── */
@@media (max-width: 900px) { @
.home-stats { grid-template-columns: repeat(3, 1fr); } @media (max-width: 900px) {
.home-sections { grid-template-columns: 1fr; } .home-stats {
grid-template-columns: repeat(3, 1fr);
}
.home-sections {
grid-template-columns: 1fr;
}
} }
@@media (max-width: 600px) { @
.hero-title { font-size: 2.2rem; } @media (max-width: 600px) {
.hero-subtitle { font-size: 0.95rem; } .hero-title {
.hero-actions { flex-direction: column; align-items: center; } font-size: 2.2rem;
.cta-button { width: 100%; justify-content: center; } }
.home-stats { grid-template-columns: repeat(2, 1fr); }
.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 { @keyframes loading-pulse {
0%, 100% { transform: scale(1); opacity: 0.7; } 0%, 100% {
50% { transform: scale(1.15); opacity: 1; } transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.15);
opacity: 1;
}
} }
.loading-title { .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="/_content/Telerik.UI.for.Blazor/css/kendo-theme-bootstrap/all.css" rel="stylesheet"/>
<link href="/css/app.css" rel="stylesheet"/> <link href="/css/app.css" rel="stylesheet"/>
<link href="/Web.styles.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"/> <link href="/favicon.png" rel="icon" type="image/png"/>
<script type="importmap"></script> <script type="importmap"></script>
</head> </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.parseLinkResult = n;
this.isInQuotes = r this.isInQuotes = r
} }
static { static {
i(this, "LinkWidget") i(this, "LinkWidget")
} }
+1 -1
View File
@@ -15,7 +15,7 @@
"state": { "state": {
"file": "Decks/Big Energy.md", "file": "Decks/Big Energy.md",
"mode": "source", "mode": "source",
"source": false "source": true
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "Big Energy" "title": "Big Energy"
+24
View File
@@ -2,19 +2,43 @@
category: Deck category: Deck
cards: cards:
- "[[Brilliant Martyr]]" - "[[Brilliant Martyr]]"
- "[[Brilliant Martyr]]"
- "[[Brilliant Martyr]]"
- "[[Kinetic Absorber]]"
- "[[Kinetic Absorber]]"
- "[[Kinetic Absorber]]" - "[[Kinetic Absorber]]"
- "[[Hidden Locus]]" - "[[Hidden Locus]]"
- "[[Hidden Locus]]"
- "[[Hidden Locus]]"
- "[[Suncursed Conduit]]"
- "[[Suncursed Conduit]]"
- "[[Suncursed Conduit]]" - "[[Suncursed Conduit]]"
- "[[Swashbuckling Diehard]]" - "[[Swashbuckling Diehard]]"
- "[[Swashbuckling Diehard]]"
- "[[Swashbuckling Diehard]]"
- "[[Debris Collector]]"
- "[[Debris Collector]]"
- "[[Debris Collector]]" - "[[Debris Collector]]"
- "[[Paradox Flow]]" - "[[Paradox Flow]]"
- "[[Paradox Flow]]"
- "[[Starfueled Medics]]"
- "[[Starfueled Medics]]"
- "[[Starfueled Medics]]" - "[[Starfueled Medics]]"
- "[[Lumbering Starseeker]]" - "[[Lumbering Starseeker]]"
- "[[Lumbering Starseeker]]"
- "[[Novathermal Mining]]"
- "[[Novathermal Mining]]"
- "[[Novathermal Mining]]" - "[[Novathermal Mining]]"
- "[[Radiant Channeling]]" - "[[Radiant Channeling]]"
- "[[Radiant Channeling]]"
- "[[Radiant Channeling]]"
- "[[Supernova]]"
- "[[Supernova]]"
- "[[Supernova]]" - "[[Supernova]]"
- "[[Gunnery Captain]]" - "[[Gunnery Captain]]"
- "[[Lightsteel Colossus]]" - "[[Lightsteel Colossus]]"
- "[[Lightsteel Colossus]]"
- "[[Devourer Spawn]]"
- "[[Devourer Spawn]]" - "[[Devourer Spawn]]"
- "[[Army of the Sun]]" - "[[Army of the Sun]]"
divers: 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. This project is a hosted Blazor WebAssembly application with a PostgreSQL database for persisting agent notes.
## Prerequisites ## Prerequisites
- **Docker Desktop**: Required for the recommended containerized setup. - **Docker Desktop**: Required for the recommended containerized setup.
- **.NET 10 SDK**: Required if you want to build or run the project locally without Docker. - **.NET 10 SDK**: Required if you want to build or run the project locally without Docker.
## 1. Running with Docker (Recommended) ## 1. Running with Docker (Recommended)
The easiest way to get everything running (App + PostgreSQL) is using Docker Compose. The easiest way to get everything running (App + PostgreSQL) is using Docker Compose.
1. **Open a terminal** in the project root (`Chrono/`). 1. **Open a terminal** in the project root (`Chrono/`).
2. **Run the following command**: 2. **Run the following command**:
```bash ```bash
docker-compose up --build docker-compose up --build
``` ```
3. **Access the Application**: 3. **Access the Application**:
- Web Interface: http://localhost:8080 - Web Interface: http://localhost:8080
- API Endpoint: http://localhost:8080/api/notes - API Endpoint: http://localhost:8080/api/notes
The database will be automatically initialized and migrations will be applied on startup. The database will be automatically initialized and migrations will be applied on startup.
## 2. Running Locally (Development) ## 2. Running Locally (Development)
If you need to run the app directly (e.g., for faster debugging): 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: 1. **Start a PostgreSQL database**. You can use the one from docker-compose if you want:
```bash ```bash
docker-compose up db docker-compose up db
``` ```
2. **Verify Connection String**: `Server/appsettings.Development.json` is pre-configured to point to `localhost`. 2. **Verify Connection String**: `Server/appsettings.Development.json` is pre-configured to point to `localhost`.
3. **Run the Server project**: 3. **Run the Server project**:
```bash ```bash
cd Server cd Server
dotnet run --launch-profile https dotnet run --launch-profile https
``` ```
4. The app will be served at the URL shown in the terminal (e.g., https://localhost:7266). 4. The app will be served at the URL shown in the terminal (e.g., https://localhost:7266).
## 3. Running Tests ## 3. Running Tests
To verify the core domain logic: To verify the core domain logic:
```bash ```bash
dotnet test dotnet test
``` ```
## 4. Key Features ## 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. - **Auto-Migrations**: The Server project automatically handles database schema updates on startup.
- **Dockerized Architecture**: Complete orchestration of the web server and database. - **Dockerized Architecture**: Complete orchestration of the web server and database.