This commit is contained in:
6d486f49
2026-08-17 00:45:08 -04:00
parent 3a2f112a73
commit 1d6207fbfe
154 changed files with 126007 additions and 47385 deletions
+489 -417
View File
@@ -1,454 +1,526 @@
@using System.Text.RegularExpressions
@using Microsoft.AspNetCore.Components.Rendering
@implements IDisposable
@namespace Chrono.Components
@if (Card != null)
{
<div class="modal-backdrop" @onclick="HandleBackdropClick"></div>
<div class="card-detail @(!HasImage ? "card-detail-noimg" : "")">
<button class="detail-close" @onclick="HandleClose"><i class="bi bi-x-lg"></i></button>
<div class="detail-layout @(!HasImage ? "layout-noimg" : "")">
@if (HasImage)
<div class="modal-backdrop" @onclick="HandleBackdropClick"></div>
<div class="card-detail @(!HasImage ? "card-detail-noimg" : "")">
<button class="detail-close" @onclick="HandleClose"><i class="bi bi-x-lg"></i></button>
<div class="detail-layout @(!HasImage ? "layout-noimg" : "")">
@if (HasImage)
{
<div class="detail-image">
<img src="@Card.ImagePath" alt="@Card.Name"
onerror="this.src='@Card.SmallImagePath';this.onerror=null;"/>
</div>
}
<div class="detail-info">
<div class="detail-header">
<h2>@Card.Name</h2>
<div class="detail-meta">
<span class="meta-badge category @Card.Category?.ToLowerInvariant()">@Card.Category</span>
@if (Card.Cost.HasValue)
{
<span class="meta-badge cost"><i class="bi bi-lightning-fill"></i> @Card.Cost</span>
}
@if (Card.Attack.HasValue)
{
<span class="meta-badge attack"><i class="bi bi-crosshair"></i> @Card.Attack</span>
}
@if (Card.Health.HasValue)
{
<span class="meta-badge health"><i class="bi bi-heart-fill"></i> @Card.Health</span>
}
@if (Card.Speed != null)
{
<span class="meta-badge speed"><i class="bi bi-wind"></i> @Card.Speed</span>
}
</div>
</div>
@if (Card.Syndicate != null)
{
<div class="detail-image">
<img src="@Card.ImagePath" alt="@Card.Name"
onerror="this.src='@Card.SmallImagePath';this.onerror=null;"/>
<div class="detail-field">
<span class="field-label"><i class="bi bi-diagram-3-fill"></i> Syndicate</span>
<span class="field-value">@Card.Syndicate</span>
</div>
}
<div class="detail-info">
<div class="detail-header">
<h2>@Card.Name</h2>
<div class="detail-meta">
<span class="meta-badge category @Card.Category?.ToLowerInvariant()">@Card.Category</span>
@if (Card.Cost.HasValue)
{
<span class="meta-badge cost"><i class="bi bi-lightning-fill"></i> @Card.Cost</span>
}
@if (Card.Attack.HasValue)
{
<span class="meta-badge attack"><i class="bi bi-crosshair"></i> @Card.Attack</span>
}
@if (Card.Health.HasValue)
{
<span class="meta-badge health"><i class="bi bi-heart-fill"></i> @Card.Health</span>
}
@if (Card.Speed != null)
{
<span class="meta-badge speed"><i class="bi bi-wind"></i> @Card.Speed</span>
}
@if (Card.Description != null)
{
<div class="detail-field description">
<span class="field-label"><i class="bi bi-chat-quote-fill"></i></span>
<div class="markdown-content">
@RenderDescription(Card.Description)
</div>
</div>
}
@if (Card.Syndicate != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-diagram-3-fill"></i> Syndicate</span>
<span class="field-value">@Card.Syndicate</span>
</div>
}
@if (Card.Set != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-collection"></i> Set</span>
<span class="field-value">@Card.Set</span>
</div>
}
@if (Card.Description != null)
{
<div class="detail-field description">
<span class="field-label"><i class="bi bi-chat-quote-fill"></i></span>
<div class="markdown-content">
@RenderDescription(Card.Description)
</div>
</div>
}
@if (Card.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(", ", Card.Archetypes)</span>
</div>
}
@if (Card.Set != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-collection"></i> Set</span>
<span class="field-value">@Card.Set</span>
</div>
}
@if (Card.ImmortalizeWhen != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-star-fill"></i> Immortalize When</span>
<span class="field-value">@Card.ImmortalizeWhen</span>
</div>
}
@if (Card.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(", ", Card.Archetypes)</span>
</div>
}
@if (Card.ImmortalizeWhen != null)
{
<div class="detail-field">
<span class="field-label"><i class="bi bi-star-fill"></i> Immortalize When</span>
<span class="field-value">@Card.ImmortalizeWhen</span>
</div>
}
@if (Card.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">
@if (Card.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">
@foreach (var name in Card.ImmortalizeTo!)
{
var targetName = name;
var target = LookupCard(targetName);
if (target != null)
{
var targetName = name;
var target = LookupCard(targetName);
if (target != null)
{
<button class="inline-card-btn"
@onclick="() => Navigate(targetName)">@targetName</button>
}
else
{
@targetName
}
<button class="inline-card-btn"
@onclick="() => Navigate(targetName)">@targetName</button>
}
else
{
@targetName
}
}
</span>
</div>
}
</div>
}
@if (Card.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">
@if (Card.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">
@{
var fromCard = LookupCard(Card.ImmortalizeFrom);
if (fromCard != null)
{
<button class="inline-card-btn"
@onclick="() => Navigate(Card.ImmortalizeFrom)">@Card.ImmortalizeFrom</button>
}
else
{
@Card.ImmortalizeFrom
}
var fromCard = LookupCard(Card.ImmortalizeFrom);
if (fromCard != null)
{
<button class="inline-card-btn"
@onclick="() => Navigate(Card.ImmortalizeFrom)">@Card.ImmortalizeFrom</button>
}
else
{
@Card.ImmortalizeFrom
}
}
</span>
</div>
}
</div>
}
@if (ShowNotes && Card.IsAgent)
{
<div class="detail-field note">
<span class="field-label"><i class="bi bi-pencil-fill"></i> Personal Note</span>
@if (canShowNotes)
{
<div class="detail-field note">
@if (Auth.IsLoggedIn)
{
<span class="field-label"><i
class="bi bi-pencil-fill"></i> Personal Note (@Auth.CurrentUsername)</span>
<textarea class="form-control note-input" @bind="currentNote" @onblur="SaveNote"
placeholder="Add a private note about this agent..."></textarea>
placeholder="Add a private note about this card..."></textarea>
@if (isSaving)
{
<span class="saving-indicator">Saving...</span>
}
</div>
}
</div>
</div>
</div>
}
@code {
[Parameter] public CardData? Card { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback<string> OnNavigate { get; set; }
[Parameter] public bool ShowNotes { get; set; }
[Inject] private HttpClient Http { get; set; } = default!;
private bool HasImage => Card?.ImageFile is { Length: > 0 } && Card.ImageFile != "placeholder.png";
private string currentNote = "";
private bool isSaving;
protected override void OnParametersSet()
{
if (Card != null && Card.IsAgent)
{
currentNote = "";
_ = LoadNote();
}
}
private async Task LoadNote()
{
if (Card == null || !Card.IsAgent) return;
try
{
var note = await Http.GetFromJsonAsync<CardNote>($"api/notes/{Uri.EscapeDataString(Card.Name)}");
currentNote = note?.Note ?? "";
}
catch
{
currentNote = "";
}
}
private async Task SaveNote()
{
if (Card == null || !Card.IsAgent) return;
isSaving = true;
try
{
await Http.PostAsJsonAsync("api/notes", new CardNote { CardName = Card.Name, Note = currentNote });
}
catch
{
}
finally
{
isSaving = false;
}
}
private void HandleClose()
{
_ = OnClose.InvokeAsync();
}
private void HandleBackdropClick()
{
_ = OnClose.InvokeAsync();
}
private void Navigate(string name)
{
var card = LookupCard(name);
if (card != null)
{
Card = card;
_ = LoadNote();
StateHasChanged();
}
else
{
_ = OnNavigate.InvokeAsync(name);
}
}
private static CardData? LookupCard(string cardName)
{
return CardDatabase.Cards.FirstOrDefault(c =>
string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase));
}
private static DocData? LookupDoc(string title)
{
return DocDatabase.Docs.FirstOrDefault(d =>
string.Equals(d.Title, title, StringComparison.OrdinalIgnoreCase));
}
private RenderFragment RenderDescription(string content)
{
return builder =>
{
if (string.IsNullOrWhiteSpace(content)) return;
var seq = 0;
var lines = content.Split(['\r', '\n'], StringSplitOptions.None);
var inList = false;
var tableLines = new List<string>();
var inTable = false;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmedLine = line.Trim();
// Handle Tables
if (trimmedLine.StartsWith("|") && trimmedLine.EndsWith("|"))
{
inTable = true;
tableLines.Add(trimmedLine);
continue;
}
if (inTable)
{
RenderTable(builder, ref seq, tableLines);
tableLines.Clear();
inTable = false;
}
// Handle Lists
if (trimmedLine.StartsWith("- ") || trimmedLine.StartsWith("* "))
{
if (!inList)
{
builder.OpenElement(seq++, "ul");
inList = true;
}
var isCardListItem = trimmedLine.Contains("[[") && trimmedLine.Contains("]]");
builder.OpenElement(seq++, "li");
RenderInlines(builder, ref seq, trimmedLine[2..], true);
builder.CloseElement();
continue;
}
if (inList)
{
builder.CloseElement(); // Close ul
inList = false;
}
// Handle Headings
if (trimmedLine.StartsWith("### "))
{
builder.OpenElement(seq++, "h4");
RenderInlines(builder, ref seq, trimmedLine[4..], false);
builder.CloseElement();
}
else if (trimmedLine.StartsWith("## "))
{
builder.OpenElement(seq++, "h3");
RenderInlines(builder, ref seq, trimmedLine[3..], false);
builder.CloseElement();
}
else if (trimmedLine.StartsWith("# "))
{
builder.OpenElement(seq++, "h2");
RenderInlines(builder, ref seq, trimmedLine[2..], false);
builder.CloseElement();
}
else if (!string.IsNullOrWhiteSpace(trimmedLine))
{
builder.OpenElement(seq++, "p");
RenderInlines(builder, ref seq, line, false);
builder.CloseElement();
}
}
if (inTable) RenderTable(builder, ref seq, tableLines);
if (inList) builder.CloseElement();
};
}
private void RenderTable(RenderTreeBuilder builder, ref int seq, List<string> lines)
{
if (lines.Count < 2) return;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "table-responsive");
builder.OpenElement(seq++, "table");
builder.AddAttribute(seq++, "class", "markdown-table");
var hasHeader = lines.Count > 2 && lines[1].Contains("---");
var startIdx = 0;
if (hasHeader)
{
builder.OpenElement(seq++, "thead");
builder.OpenElement(seq++, "tr");
var cells = lines[0].Split('|', StringSplitOptions.RemoveEmptyEntries);
foreach (var cell in cells)
{
builder.OpenElement(seq++, "th");
RenderInlines(builder, ref seq, cell.Trim(), false);
builder.CloseElement();
}
builder.CloseElement();
builder.CloseElement();
startIdx = 2;
}
builder.OpenElement(seq++, "tbody");
for (var i = startIdx; i < lines.Count; i++)
{
if (i == 1 && hasHeader) continue;
builder.OpenElement(seq++, "tr");
var cells = lines[i].Split('|', StringSplitOptions.RemoveEmptyEntries);
foreach (var cell in cells)
{
builder.OpenElement(seq++, "td");
RenderInlines(builder, ref seq, cell.Trim(), false);
builder.CloseElement();
}
builder.CloseElement();
}
builder.CloseElement();
builder.CloseElement();
builder.CloseElement();
}
private void RenderInlines(RenderTreeBuilder builder, ref int seq, string text, bool renderAsImage)
{
var remaining = text;
while (!string.IsNullOrEmpty(remaining))
{
var boldMatch = Regex.Match(remaining, @"\*\*(.*?)\*\*");
var italicMatch = Regex.Match(remaining, @"\*(.*?)\*");
var cardMatch = Regex.Match(remaining, @"\[\[(.*?)\]\]");
var bestIdx = int.MaxValue;
Match? bestMatch = null;
var matchType = 0; // 1: bold, 2: italic, 3: card
if (boldMatch.Success && boldMatch.Index < bestIdx)
{
bestIdx = boldMatch.Index;
bestMatch = boldMatch;
matchType = 1;
}
if (italicMatch.Success && italicMatch.Index < bestIdx)
{
bestIdx = italicMatch.Index;
bestMatch = italicMatch;
matchType = 2;
}
if (cardMatch.Success && cardMatch.Index < bestIdx)
{
bestIdx = cardMatch.Index;
bestMatch = cardMatch;
matchType = 3;
}
if (bestMatch != null)
{
if (bestIdx > 0)
builder.AddContent(seq++, remaining[..bestIdx]);
var innerText = bestMatch.Groups[1].Value;
if (matchType == 1) // Bold
{
builder.OpenElement(seq++, "strong");
builder.AddContent(seq++, innerText);
builder.CloseElement();
}
else if (matchType == 2) // Italic
{
builder.OpenElement(seq++, "em");
builder.AddContent(seq++, innerText);
builder.CloseElement();
}
else if (matchType == 3) // Card Link / Doc Link
{
var card = LookupCard(innerText);
var doc = LookupDoc(innerText);
if (card != null || doc != null)
{
builder.OpenElement(seq++, "button");
builder.AddAttribute(seq++, "class", "inline-card-btn");
builder.AddAttribute(seq++, "onclick", EventCallback.Factory.Create(this, () => Navigate(innerText)));
builder.AddContent(seq++, innerText);
builder.CloseElement();
}
else
{
builder.AddContent(seq++, innerText);
<span class="field-label"><i class="bi bi-pencil-fill"></i> Personal Note</span>
<div class="small text-secondary mt-1">
<a href="login" class="text-warning fw-semibold text-decoration-none">
<i class="bi bi-box-arrow-in-right me-1"></i> Log in
</a> to view and write private notes for this card.
</div>
}
}
</div>
}
</div>
</div>
</div>
}
remaining = remaining[(bestIdx + bestMatch.Length)..];
}
else
{
builder.AddContent(seq++, remaining);
remaining = "";
}
}
@code {
[Parameter] public CardData? Card { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback<string> OnNavigate { get; set; }
[Parameter] public bool ShowNotes { get; set; } = true;
[Inject] private CardNotesGraphQLService NotesService { get; set; } = default!;
[Inject] private NetworkStatusService NetworkStatus { get; set; } = default!;
[Inject] private AuthService Auth { get; set; } = default!;
private bool HasImage => Card?.ImageFile is { Length: > 0 } && Card.ImageFile != "placeholder.png";
private string currentNote = "";
private bool isSaving;
private bool isApiOnline;
private bool canShowNotes => ShowNotes && NetworkStatus.IsOnline;
protected override void OnInitialized()
{
NetworkStatus.StatusChanged += HandleStatusChanged;
Auth.AuthStateChanged += HandleAuthStateChanged;
}
private void HandleAuthStateChanged()
{
if (Auth.IsLoggedIn && Card != null && NetworkStatus.IsOnline)
{
_ = LoadNote();
}
else if (!Auth.IsLoggedIn)
{
currentNote = "";
}
InvokeAsync(StateHasChanged);
}
private async void HandleStatusChanged(bool online)
{
if (online)
{
isApiOnline = await NotesService.IsApiOnlineAsync();
if (isApiOnline && Card != null && Auth.IsLoggedIn)
{
await LoadNote();
}
}
else
{
isApiOnline = false;
}
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync()
{
if (Card != null && ShowNotes)
{
currentNote = "";
if (NetworkStatus.IsOnline)
{
isApiOnline = await NotesService.IsApiOnlineAsync();
if (isApiOnline && Auth.IsLoggedIn)
{
await LoadNote();
}
}
else
{
isApiOnline = false;
}
}
}
private async Task LoadNote()
{
if (Card == null || !Auth.IsLoggedIn) return;
try
{
currentNote = await NotesService.GetNoteAsync(Card.Name);
}
catch
{
currentNote = "";
}
}
private async Task SaveNote()
{
if (Card == null || !canShowNotes || !Auth.IsLoggedIn) return;
isSaving = true;
try
{
await NotesService.SaveNoteAsync(Card.Name, currentNote);
}
catch
{
}
finally
{
isSaving = false;
}
}
public void Dispose()
{
NetworkStatus.StatusChanged -= HandleStatusChanged;
Auth.AuthStateChanged -= HandleAuthStateChanged;
}
private void HandleClose()
{
_ = OnClose.InvokeAsync();
}
private void HandleBackdropClick()
{
_ = OnClose.InvokeAsync();
}
private void Navigate(string name)
{
var card = LookupCard(name);
if (card != null)
{
Card = card;
_ = LoadNote();
StateHasChanged();
}
else
{
_ = OnNavigate.InvokeAsync(name);
}
}
private static CardData? LookupCard(string cardName)
{
return CardDatabase.Cards.FirstOrDefault(c =>
string.Equals(c.Name, cardName, StringComparison.OrdinalIgnoreCase));
}
private static DocData? LookupDoc(string title)
{
return DocDatabase.Docs.FirstOrDefault(d =>
string.Equals(d.Title, title, StringComparison.OrdinalIgnoreCase));
}
private RenderFragment RenderDescription(string content)
{
return builder =>
{
if (string.IsNullOrWhiteSpace(content)) return;
var seq = 0;
var lines = content.Split(['\r', '\n'], StringSplitOptions.None);
var inList = false;
var tableLines = new List<string>();
var inTable = false;
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
var trimmedLine = line.Trim();
// Handle Tables
if (trimmedLine.StartsWith("|") && trimmedLine.EndsWith("|"))
{
inTable = true;
tableLines.Add(trimmedLine);
continue;
}
if (inTable)
{
RenderTable(builder, ref seq, tableLines);
tableLines.Clear();
inTable = false;
}
// Handle Lists
if (trimmedLine.StartsWith("- ") || trimmedLine.StartsWith("* "))
{
if (!inList)
{
builder.OpenElement(seq++, "ul");
inList = true;
}
var isCardListItem = trimmedLine.Contains("[[") && trimmedLine.Contains("]]");
builder.OpenElement(seq++, "li");
RenderInlines(builder, ref seq, trimmedLine[2..], true);
builder.CloseElement();
continue;
}
if (inList)
{
builder.CloseElement(); // Close ul
inList = false;
}
// Handle Headings
if (trimmedLine.StartsWith("### "))
{
builder.OpenElement(seq++, "h4");
RenderInlines(builder, ref seq, trimmedLine[4..], false);
builder.CloseElement();
}
else if (trimmedLine.StartsWith("## "))
{
builder.OpenElement(seq++, "h3");
RenderInlines(builder, ref seq, trimmedLine[3..], false);
builder.CloseElement();
}
else if (trimmedLine.StartsWith("# "))
{
builder.OpenElement(seq++, "h2");
RenderInlines(builder, ref seq, trimmedLine[2..], false);
builder.CloseElement();
}
else if (!string.IsNullOrWhiteSpace(trimmedLine))
{
builder.OpenElement(seq++, "p");
RenderInlines(builder, ref seq, line, false);
builder.CloseElement();
}
}
if (inTable) RenderTable(builder, ref seq, tableLines);
if (inList) builder.CloseElement();
};
}
private void RenderTable(RenderTreeBuilder builder, ref int seq, List<string> lines)
{
if (lines.Count < 2) return;
builder.OpenElement(seq++, "div");
builder.AddAttribute(seq++, "class", "table-responsive");
builder.OpenElement(seq++, "table");
builder.AddAttribute(seq++, "class", "markdown-table");
var hasHeader = lines.Count > 2 && lines[1].Contains("---");
var startIdx = 0;
if (hasHeader)
{
builder.OpenElement(seq++, "thead");
builder.OpenElement(seq++, "tr");
var cells = lines[0].Split('|', StringSplitOptions.RemoveEmptyEntries);
foreach (var cell in cells)
{
builder.OpenElement(seq++, "th");
RenderInlines(builder, ref seq, cell.Trim(), false);
builder.CloseElement();
}
builder.CloseElement();
builder.CloseElement();
startIdx = 2;
}
builder.OpenElement(seq++, "tbody");
for (var i = startIdx; i < lines.Count; i++)
{
if (i == 1 && hasHeader) continue;
builder.OpenElement(seq++, "tr");
var cells = lines[i].Split('|', StringSplitOptions.RemoveEmptyEntries);
foreach (var cell in cells)
{
builder.OpenElement(seq++, "td");
RenderInlines(builder, ref seq, cell.Trim(), false);
builder.CloseElement();
}
builder.CloseElement();
}
builder.CloseElement();
builder.CloseElement();
builder.CloseElement();
}
private void RenderInlines(RenderTreeBuilder builder, ref int seq, string text, bool renderAsImage)
{
var remaining = text;
while (!string.IsNullOrEmpty(remaining))
{
var boldMatch = Regex.Match(remaining, @"\*\*(.*?)\*\*");
var italicMatch = Regex.Match(remaining, @"\*(.*?)\*");
var cardMatch = Regex.Match(remaining, @"\[\[(.*?)\]\]");
var bestIdx = int.MaxValue;
Match? bestMatch = null;
var matchType = 0; // 1: bold, 2: italic, 3: card
if (boldMatch.Success && boldMatch.Index < bestIdx)
{
bestIdx = boldMatch.Index;
bestMatch = boldMatch;
matchType = 1;
}
if (italicMatch.Success && italicMatch.Index < bestIdx)
{
bestIdx = italicMatch.Index;
bestMatch = italicMatch;
matchType = 2;
}
if (cardMatch.Success && cardMatch.Index < bestIdx)
{
bestIdx = cardMatch.Index;
bestMatch = cardMatch;
matchType = 3;
}
if (bestMatch != null)
{
if (bestIdx > 0)
builder.AddContent(seq++, remaining[..bestIdx]);
var innerText = bestMatch.Groups[1].Value;
if (matchType == 1) // Bold
{
builder.OpenElement(seq++, "strong");
builder.AddContent(seq++, innerText);
builder.CloseElement();
}
else if (matchType == 2) // Italic
{
builder.OpenElement(seq++, "em");
builder.AddContent(seq++, innerText);
builder.CloseElement();
}
else if (matchType == 3) // Card Link / Doc Link
{
var card = LookupCard(innerText);
var doc = LookupDoc(innerText);
if (card != null || doc != null)
{
builder.OpenElement(seq++, "button");
builder.AddAttribute(seq++, "class", "inline-card-btn");
builder.AddAttribute(seq++, "onclick", EventCallback.Factory.Create(this, () => Navigate(innerText)));
builder.AddContent(seq++, innerText);
builder.CloseElement();
}
else
{
builder.AddContent(seq++, innerText);
}
}
remaining = remaining[(bestIdx + bestMatch.Length)..];
}
else
{
builder.AddContent(seq++, remaining);
remaining = "";
}
}
}
}
@@ -10,9 +10,9 @@
<article class="content px-4">
<TelerikRootComponent>
@Body
<Shared.Components.CardPreview />
<Shared.Components.DocDetailModal Doc="@PreviewService.SelectedDoc"
OnClose="@(() => PreviewService.SelectDoc(null))" />
<Shared.Components.CardPreview/>
<Shared.Components.DocDetailModal Doc="@PreviewService.SelectedDoc"
OnClose="@(() => PreviewService.SelectDoc(null))"/>
</TelerikRootComponent>
</article>
</main>
+36 -1
View File
@@ -1,10 +1,14 @@
@inject AuthService Auth
@inject NetworkStatusService NetworkStatus
@implements IDisposable
<div class="top-row ps-3 pe-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center justify-content-between p-0">
<a class="navbar-brand" href="">
<i class="bi bi-hourglass-split me-2 text-warning"></i> Chrono CCG
</a>
<div class="d-flex align-items-center gap-2">
<NetworkStatusChip />
<NetworkStatusChip/>
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
@@ -44,6 +48,15 @@
<i class="bi bi-book-fill nav-icon"></i> Docs
</NavLink>
</div>
@if (NetworkStatus.IsOnline)
{
<div class="nav-item px-3">
<NavLink class="nav-link" href="login">
<i class="bi @(Auth.IsLoggedIn ? "bi-person-check-fill text-success" : "bi-person-fill text-warning") nav-icon"></i>
@(Auth.IsLoggedIn ? Auth.CurrentUsername : "Login")
</NavLink>
</div>
}
</nav>
</div>
@@ -52,9 +65,31 @@
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
protected override void OnInitialized()
{
NetworkStatus.StatusChanged += OnStateChanged;
Auth.AuthStateChanged += OnStateChanged;
}
private void OnStateChanged(bool isOnline)
{
InvokeAsync(StateHasChanged);
}
private void OnStateChanged()
{
InvokeAsync(StateHasChanged);
}
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
public void Dispose()
{
NetworkStatus.StatusChanged -= OnStateChanged;
Auth.AuthStateChanged -= OnStateChanged;
}
}
@@ -9,6 +9,7 @@
</div>
@code {
protected override async Task OnInitializedAsync()
{
NetworkStatus.StatusChanged += OnStatusChanged;
@@ -24,4 +25,5 @@
{
NetworkStatus.StatusChanged -= OnStatusChanged;
}
}
+9 -9
View File
@@ -95,26 +95,26 @@
<span class="filter-label">Filters:</span>
@if (categoryFilter != "")
{
<span class="filter-chip">
<span class="filter-chip">
<i class="bi bi-tag-fill"></i> @categoryFilter
<button @onclick="ClearCategoryFilter"><i class="bi bi-x"></i></button>
<button @onclick="ClearCategoryFilter"><i class="bi bi-x"></i></button>
</span>
}
@if (syndicateFilter.Count > 0)
{
@foreach (var s in syndicateFilter)
{
<span class="filter-chip">
@foreach (var s in syndicateFilter)
{
<span class="filter-chip">
<i class="bi bi-diagram-3-fill"></i> @s
<button @onclick="() => SetSyndicate(s)"><i class="bi bi-x"></i></button>
<button @onclick="() => SetSyndicate(s)"><i class="bi bi-x"></i></button>
</span>
}
}
}
@if (costFilter != "")
{
<span class="filter-chip">
<span class="filter-chip">
<i class="bi bi-lightning-fill"></i> Cost @costFilter
<button @onclick="ClearCostFilter"><i class="bi bi-x"></i></button>
<button @onclick="ClearCostFilter"><i class="bi bi-x"></i></button>
</span>
}
<button class="clear-all" @onclick="ClearFilters">Clear all</button>
+173
View File
@@ -0,0 +1,173 @@
@page "/login"
@inject AuthService Auth
@inject NetworkStatusService NetworkStatus
@inject NavigationManager Navigation
@implements IDisposable
<PageTitle>Login - Chrono CCG</PageTitle>
<div class="container py-5" style="max-width: 500px;">
<div class="card bg-dark text-white border-secondary shadow">
<div class="card-header border-secondary text-center py-3">
<h4 class="mb-0 fw-bold">
<i class="bi bi-shield-lock-fill text-warning me-2"></i> Chrono API Account
</h4>
</div>
<div class="card-body p-4">
@if (!NetworkStatus.IsOnline)
{
<div class="alert alert-warning d-flex align-items-center mb-0" role="alert">
<i class="bi bi-wifi-off fs-3 me-3"></i>
<div>
<h6 class="alert-heading fw-bold mb-1">Offline Mode</h6>
<p class="small mb-0">Login is only available when you are online. Please check your network
connection to access the Chrono CCG API and sync notes.</p>
</div>
</div>
}
else if (Auth.IsLoggedIn)
{
<div class="text-center py-3">
<div class="mb-3">
<i class="bi bi-person-check-fill text-success" style="font-size: 3rem;"></i>
</div>
<h5 class="fw-bold mb-1">Logged In</h5>
<p class="text-secondary small mb-4">
You are signed in as <span
class="text-warning fw-bold font-monospace">@Auth.CurrentUsername</span>. Your private card
notes and decks are active.
</p>
<div class="d-grid gap-2">
<a href="" class="btn btn-warning fw-semibold">
<i class="bi bi-collection-fill me-1"></i> Browse Cards
</a>
<button class="btn btn-outline-danger" @onclick="HandleLogout">
<i class="bi bi-box-arrow-right me-1"></i> Log Out
</button>
</div>
</div>
}
else
{
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger py-2 small" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i> @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success py-2 small" role="alert">
<i class="bi bi-check-circle-fill me-2"></i> @successMessage
</div>
}
<form @onsubmit="HandleLogin">
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">USERNAME</label>
<div class="input-group">
<span class="input-group-text bg-dark border-secondary text-secondary"><i
class="bi bi-person-fill"></i></span>
<input type="text" class="form-control bg-dark border-secondary text-white"
placeholder="e.g. player1 or admin" @bind="username" disabled="@isLoading" required/>
</div>
</div>
<div class="mb-4">
<label class="form-label text-secondary small fw-semibold">PASSWORD</label>
<div class="input-group">
<span class="input-group-text bg-dark border-secondary text-secondary"><i
class="bi bi-key-fill"></i></span>
<input type="password" class="form-control bg-dark border-secondary text-white"
placeholder="Enter password" @bind="password" disabled="@isLoading" required/>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-warning fw-bold" disabled="@isLoading">
@if (isLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
<span>Logging In...</span>
}
else
{
<i class="bi bi-box-arrow-in-right me-1"></i>
<span>Log In</span>
}
</button>
</div>
</form>
}
</div>
</div>
</div>
@code {
private string username = "";
private string password = "";
private bool isLoading;
private string? errorMessage;
private string? successMessage;
protected override async Task OnInitializedAsync()
{
NetworkStatus.StatusChanged += OnStatusChanged;
Auth.AuthStateChanged += OnAuthStateChanged;
await Auth.InitializeAsync();
}
private void OnStatusChanged(bool isOnline)
{
InvokeAsync(StateHasChanged);
}
private void OnAuthStateChanged()
{
InvokeAsync(StateHasChanged);
}
private async Task HandleLogin()
{
errorMessage = null;
successMessage = null;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
errorMessage = "Please enter both username and password.";
return;
}
isLoading = true;
try
{
var result = await Auth.LoginAsync(username.Trim(), password);
if (result.Success)
{
successMessage = result.Message;
Navigation.NavigateTo("");
}
else
{
errorMessage = result.Message;
}
}
finally
{
isLoading = false;
}
}
private async Task HandleLogout()
{
await Auth.LogoutAsync();
}
public void Dispose()
{
NetworkStatus.StatusChanged -= OnStatusChanged;
Auth.AuthStateChanged -= OnAuthStateChanged;
}
}
+2
View File
@@ -14,5 +14,7 @@ builder.Services.AddSingleton<CardRenderingService>();
builder.Services.AddScoped<CardPreviewService>();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddScoped<NetworkStatusService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<CardNotesGraphQLService>();
await builder.Build().RunAsync();
+1 -2
View File
@@ -1,5 +1,4 @@
@using Web.Components.Layout
<Router AppAssembly="@typeof(Routes).Assembly" AdditionalAssemblies="new[] { typeof(Home).Assembly }"
<Router AppAssembly="@typeof(Routes).Assembly" AdditionalAssemblies="new[] { typeof(Home).Assembly }"
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
+199
View File
@@ -0,0 +1,199 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.JSInterop;
namespace Web.Services;
public class AuthService
{
private readonly string _graphqlEndpoint;
private readonly HttpClient _http;
private readonly IJSRuntime? _jsRuntime;
private readonly NetworkStatusService _networkStatus;
public AuthService(HttpClient http, NetworkStatusService networkStatus, IServiceProvider serviceProvider)
{
_http = http;
_networkStatus = networkStatus;
_jsRuntime = serviceProvider.GetService(typeof(IJSRuntime)) as IJSRuntime;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 5256
? "/graphql"
: "http://localhost:5256/graphql";
}
public bool IsLoggedIn => !string.IsNullOrEmpty(AuthToken);
public string? CurrentUsername { get; private set; }
public string? AuthToken { get; private set; }
public event Action? AuthStateChanged;
public async Task InitializeAsync()
{
if (_jsRuntime != null)
try
{
var storedToken = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "chrono_auth_token");
var storedUser = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", "chrono_auth_user");
if (!string.IsNullOrEmpty(storedToken) && !string.IsNullOrEmpty(storedUser))
{
AuthToken = storedToken;
CurrentUsername = storedUser;
AuthStateChanged?.Invoke();
}
}
catch
{
// LocalStorage not available in testing or SSR
}
}
public async Task<AuthResult> LoginAsync(string username, string password)
{
if (!_networkStatus.IsOnline)
return new AuthResult { Success = false, Message = "Cannot login while offline." };
try
{
var request = new GraphQLRequest
{
Query = @"mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
success
token
username
message
}
}",
Variables = new Dictionary<string, object>
{
["username"] = username,
["password"] = password
}
};
var response = await SendGraphQLAsync<LoginResponseData>(request);
var payload = response?.Data?.Login;
if (payload != null && payload.Success && !string.IsNullOrEmpty(payload.Token))
{
AuthToken = payload.Token;
CurrentUsername = payload.Username ?? username;
if (_jsRuntime != null)
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "chrono_auth_token", AuthToken);
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", "chrono_auth_user", CurrentUsername);
}
catch
{
}
AuthStateChanged?.Invoke();
return new AuthResult { Success = true, Message = payload.Message ?? "Login successful" };
}
return new AuthResult { Success = false, Message = payload?.Message ?? "Invalid username or password." };
}
catch (Exception ex)
{
return new AuthResult { Success = false, Message = $"Login error: {ex.Message}" };
}
}
public async Task LogoutAsync()
{
AuthToken = null;
CurrentUsername = null;
if (_jsRuntime != null)
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "chrono_auth_token");
await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", "chrono_auth_user");
}
catch
{
}
AuthStateChanged?.Invoke();
}
public void SetAuth(string username, string token)
{
CurrentUsername = username;
AuthToken = token;
AuthStateChanged?.Invoke();
}
private async Task<GraphQLResponse<T>?> SendGraphQLAsync<T>(GraphQLRequest request)
{
try
{
var httpResponse = await _http.PostAsJsonAsync(_graphqlEndpoint, request);
if (httpResponse.IsSuccessStatusCode)
return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
if (_graphqlEndpoint == "/graphql")
{
var fallbackResponse = await _http.PostAsJsonAsync("http://localhost:5256/graphql", request);
if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
}
catch
{
try
{
var fallbackResponse = await _http.PostAsJsonAsync("http://localhost:5256/graphql", request);
if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
catch
{
}
}
return null;
}
public class AuthResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
private class GraphQLRequest
{
[JsonPropertyName("query")] public string Query { get; set; } = string.Empty;
[JsonPropertyName("variables")] public Dictionary<string, object>? Variables { get; set; }
}
private class GraphQLResponse<T>
{
[JsonPropertyName("data")] public T? Data { get; set; }
[JsonPropertyName("errors")] public List<GraphQLError>? Errors { get; set; }
}
private class GraphQLError
{
[JsonPropertyName("message")] public string Message { get; set; } = string.Empty;
}
private class LoginResponseData
{
[JsonPropertyName("login")] public LoginPayloadData? Login { get; set; }
}
private class LoginPayloadData
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("token")] public string? Token { get; set; }
[JsonPropertyName("username")] public string? Username { get; set; }
[JsonPropertyName("message")] public string? Message { get; set; }
}
}
@@ -0,0 +1,177 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Model;
namespace Web.Services;
public class CardNotesGraphQLService
{
private readonly AuthService _authService;
private readonly string _graphqlEndpoint;
private readonly HttpClient _http;
private readonly NetworkStatusService _networkStatus;
public CardNotesGraphQLService(HttpClient http, NetworkStatusService networkStatus, AuthService authService)
{
_http = http;
_networkStatus = networkStatus;
_authService = authService;
_graphqlEndpoint = http.BaseAddress != null && http.BaseAddress.Port == 5256
? "/graphql"
: "http://localhost:5256/graphql";
}
public async Task<bool> IsApiOnlineAsync()
{
if (!_networkStatus.IsOnline) return false;
try
{
var request = new GraphQLRequest
{
Query = "query { __typename }"
};
var response = await SendGraphQLAsync<object>(request);
return response != null;
}
catch
{
return false;
}
}
public async Task<string> GetNoteAsync(string cardName)
{
if (!_networkStatus.IsOnline || !_authService.IsLoggedIn) return string.Empty;
try
{
var request = new GraphQLRequest
{
Query = @"query GetCardNote($cardName: String!) {
note(cardName: $cardName) {
username
cardName
note
}
}",
Variables = new Dictionary<string, object>
{
["cardName"] = cardName
}
};
var response = await SendGraphQLAsync<GetNoteData>(request);
return response?.Data?.Note?.Note ?? string.Empty;
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] Failed to fetch note for {cardName}: {ex.Message}");
return string.Empty;
}
}
public async Task<bool> SaveNoteAsync(string cardName, string note)
{
if (!_networkStatus.IsOnline || !_authService.IsLoggedIn) return false;
try
{
var request = new GraphQLRequest
{
Query = @"mutation SaveCardNote($cardName: String!, $note: String!) {
saveNote(cardName: $cardName, note: $note) {
username
cardName
note
}
}",
Variables = new Dictionary<string, object>
{
["cardName"] = cardName,
["note"] = note
}
};
var response = await SendGraphQLAsync<SaveNoteData>(request);
return response?.Data?.SaveNote != null;
}
catch (Exception ex)
{
Console.WriteLine($"[WARNING] Failed to save note for {cardName}: {ex.Message}");
return false;
}
}
private async Task<GraphQLResponse<T>?> SendGraphQLAsync<T>(GraphQLRequest request)
{
async Task<HttpResponseMessage> SendToUrlAsync(string url)
{
var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(request)
};
if (!string.IsNullOrEmpty(_authService.AuthToken))
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _authService.AuthToken);
return await _http.SendAsync(httpRequest);
}
try
{
var httpResponse = await SendToUrlAsync(_graphqlEndpoint);
if (httpResponse.IsSuccessStatusCode)
return await httpResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
if (_graphqlEndpoint == "/graphql")
{
var fallbackResponse = await SendToUrlAsync("http://localhost:5256/graphql");
if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
}
catch
{
try
{
var fallbackResponse = await SendToUrlAsync("http://localhost:5256/graphql");
if (fallbackResponse.IsSuccessStatusCode)
return await fallbackResponse.Content.ReadFromJsonAsync<GraphQLResponse<T>>();
}
catch
{
}
}
return null;
}
public class GraphQLRequest
{
[JsonPropertyName("query")] public string Query { get; set; } = string.Empty;
[JsonPropertyName("variables")] public Dictionary<string, object>? Variables { get; set; }
}
public class GraphQLResponse<T>
{
[JsonPropertyName("data")] public T? Data { get; set; }
[JsonPropertyName("errors")] public List<GraphQLError>? Errors { get; set; }
}
public class GraphQLError
{
[JsonPropertyName("message")] public string Message { get; set; } = string.Empty;
}
public class GetNoteData
{
[JsonPropertyName("note")] public CardNote? Note { get; set; }
}
public class SaveNoteData
{
[JsonPropertyName("saveNote")] public CardNote? SaveNote { get; set; }
}
}
+40 -40
View File
@@ -1,55 +1,19 @@
namespace Web.Services;
using Microsoft.JSInterop;
namespace Web.Services;
public class NetworkStatusService : IAsyncDisposable, IDisposable
{
private readonly IJSRuntime _jsRuntime;
private DotNetObjectReference<NetworkStatusService>? _dotNetRef;
private bool _isOnline = true;
private bool _initialized;
public event Action<bool>? StatusChanged;
public bool IsOnline => _isOnline;
public NetworkStatusService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
try
{
_dotNetRef = DotNetObjectReference.Create(this);
_isOnline = await _jsRuntime.InvokeAsync<bool>("networkStatus.initialize", _dotNetRef);
StatusChanged?.Invoke(_isOnline);
}
catch (Exception ex)
{
Console.WriteLine($"[NetworkStatusService] Failed to initialize: {ex.Message}");
}
}
[JSInvokable]
public void SetOnlineStatus(bool isOnline)
{
if (_isOnline != isOnline)
{
_isOnline = isOnline;
StatusChanged?.Invoke(_isOnline);
}
}
public void Dispose()
{
_dotNetRef?.Dispose();
_dotNetRef = null;
}
public bool IsOnline { get; private set; } = true;
public async ValueTask DisposeAsync()
{
@@ -63,7 +27,43 @@ public class NetworkStatusService : IAsyncDisposable, IDisposable
{
// Ignore disposal errors
}
_dotNetRef.Dispose();
}
}
}
public void Dispose()
{
_dotNetRef?.Dispose();
_dotNetRef = null;
}
public event Action<bool>? StatusChanged;
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
try
{
_dotNetRef = DotNetObjectReference.Create(this);
IsOnline = await _jsRuntime.InvokeAsync<bool>("networkStatus.initialize", _dotNetRef);
StatusChanged?.Invoke(IsOnline);
}
catch (Exception ex)
{
Console.WriteLine($"[NetworkStatusService] Failed to initialize: {ex.Message}");
}
}
[JSInvokable]
public void SetOnlineStatus(bool isOnline)
{
if (IsOnline != isOnline)
{
IsOnline = isOnline;
StatusChanged?.Invoke(IsOnline);
}
}
}