From b5c295f45cf1fcc7b1688e06a963e60d19d9606f Mon Sep 17 00:00:00 2001 From: neckfire Date: Wed, 15 Jul 2026 11:30:22 +0200 Subject: [PATCH] admin: interactive tables (sort/filter/search/paging) via client-side JS engine Players and Catalog pages now fetch /admin/api/{players,catalog} JSON and render a self-contained vanilla-JS data table: clickable column sort, live search, dropdown filters (auth / category / rarity), page-size selector + prev/next paging, result count. No CDN, works offline. Server-rendered dashboard, player detail and titledata pages unchanged. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/AdminController.cs | 244 +++++++++++------- 1 file changed, 145 insertions(+), 99 deletions(-) diff --git a/src/Prospect.Server.Api/Controllers/AdminController.cs b/src/Prospect.Server.Api/Controllers/AdminController.cs index 9c70312..ccd0b86 100644 --- a/src/Prospect.Server.Api/Controllers/AdminController.cs +++ b/src/Prospect.Server.Api/Controllers/AdminController.cs @@ -14,6 +14,8 @@ namespace Prospect.Server.Api.Controllers; // Read-only back-office to visualise everything relevant in the database. // Served at /admin, restricted to LAN / loopback callers (an admin panel must not be // reachable from the public internet even though :8443 is port-forwarded). +// List pages (players, catalog) are driven by a small self-contained client-side table +// engine (sort / filter / search / paging) fed by the /admin/api/* JSON endpoints. [Route("admin")] public class AdminController : ControllerBase { @@ -51,58 +53,45 @@ public class AdminController : ControllerBase var sb = new StringBuilder(); sb.Append("
"); sb.Append(Card("Joueurs", users.Count.ToString())); - sb.Append(Card("Entités (title_player)", entities.Count.ToString())); + sb.Append(Card("Entités", entities.Count.ToString())); sb.Append(Card("Docs UserData", dataCount.ToString())); sb.Append(Card("Clés TitleData", titleKeys.Count.ToString())); + sb.Append(Card("Catalogue", _gameRef.All.Count.ToString())); sb.Append("
"); - - sb.Append("

Joueurs récents

"); - sb.Append(""); - foreach (var u in users.Take(15)) - { - var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; - sb.Append($"" + - $""); - } - sb.Append("
DisplayNamePlayFab IdAuth
{Esc(u.DisplayName)}{Esc(u.Id)}{Esc(auth)}Voir
"); - sb.Append("

Tous les joueurs →   TitleData →

"); - + sb.Append("

Joueurs →   Catalogue →   TitleData →

"); return Html(Layout("Dashboard", sb.ToString())); } [HttpGet("players")] - public async Task Players([FromQuery] string? q) + public IActionResult Players() { if (!IsLan()) return Denied(); + var body = + "

Joueurs

" + + "

⟳ Backfill pseudos Steam

" + + "
" + + ""; + return Html(Layout("Joueurs", body)); + } - var users = await _userService.GetAllAsync(); - var entities = (await _entityService.GetAllAsync()) - .GroupBy(e => e.UserId) - .ToDictionary(g => g.Key, g => g.First().Id); - - if (!string.IsNullOrWhiteSpace(q)) - { - var needle = q.Trim().ToLowerInvariant(); - users = users.Where(u => - (u.DisplayName ?? "").ToLowerInvariant().Contains(needle) || - (u.Id ?? "").ToLowerInvariant().Contains(needle) || - (u.Auth != null && u.Auth.Any(a => (a.Key ?? "").ToLowerInvariant().Contains(needle)))).ToList(); - } - - var sb = new StringBuilder(); - sb.Append($""); - sb.Append($"

{users.Count} joueur(s)

"); - sb.Append(""); - foreach (var u in users.OrderBy(u => u.DisplayName)) - { - var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; - var ent = entities.TryGetValue(u.Id, out var eid) ? eid : ""; - sb.Append($"" + - $""); - } - sb.Append("
DisplayNamePlayFab IdEntity IdAuth
{Esc(u.DisplayName)}{Esc(u.Id)}{Esc(ent)}{Esc(auth)}Voir
"); - return Html(Layout("Joueurs", sb.ToString())); + [HttpGet("catalog")] + public IActionResult Catalog() + { + if (!IsLan()) return Denied(); + var body = + "

Catalogue

" + + "

Référence dataminée (TCF-Wiki) — " + _gameRef.All.Count + " entrées.

" + + "
" + + ""; + return Html(Layout("Catalogue", body)); } [HttpGet("players/{id}")] @@ -119,10 +108,10 @@ public class AdminController : ControllerBase var sb = new StringBuilder(); var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; + sb.Append($"

← Joueurs

"); sb.Append($"

{Esc(user.DisplayName)}

"); sb.Append($"

PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}

"); - // Summary cards sb.Append("
"); if (map.TryGetValue("Balance", out var bal)) { @@ -138,14 +127,12 @@ public class AdminController : ControllerBase sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim())); sb.Append("
"); - // Inventory table if (map.TryGetValue("Inventory", out var inv)) { sb.Append("

Inventaire

"); sb.Append(InventoryTable(inv.Value)); } - // All keys sb.Append("

Toutes les données

"); foreach (var d in data) { @@ -153,7 +140,6 @@ public class AdminController : ControllerBase $" · {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}"); sb.Append($"
{Esc(Pretty(d.Value))}
"); } - return Html(Layout("Joueur · " + user.DisplayName, sb.ToString())); } @@ -164,6 +150,7 @@ public class AdminController : ControllerBase var all = _titleDataService.Find(new List()); var sb = new StringBuilder(); + sb.Append("

TitleData

"); sb.Append(""); foreach (var kv in all.OrderBy(k => k.Key)) sb.Append($"" + @@ -181,11 +168,10 @@ public class AdminController : ControllerBase if (!all.TryGetValue(key, out var value)) return Html(Layout("Clé introuvable", "

Aucune clé TitleData avec ce nom.

")); - // Large values (Blueprints, Contracts…) can be huge — pretty-print only if reasonable. var body = value != null && value.Length > 300_000 ? $"

Valeur volumineuse ({value.Length} car) — affichage brut.

{Esc(value)}
" : $"
{Esc(Pretty(value))}
"; - return Html(Layout("TitleData · " + key, $"

{Esc(key)}

" + body)); + return Html(Layout("TitleData · " + key, $"

← TitleData

{Esc(key)}

" + body)); } [HttpGet("backfill-names")] @@ -220,35 +206,37 @@ public class AdminController : ControllerBase return Html(Layout("Backfill", body)); } - [HttpGet("catalog")] - public IActionResult Catalog([FromQuery] string? q, [FromQuery] string? cat) + // ---------- JSON API (consumed by the client-side table engine) ---------- + + [HttpGet("api/players")] + public async Task ApiPlayers() { if (!IsLan()) return Denied(); - - var all = _gameRef.All; - IEnumerable> items = all; - if (!string.IsNullOrWhiteSpace(cat)) - items = items.Where(kv => kv.Value.Category == cat); - if (!string.IsNullOrWhiteSpace(q)) + var users = await _userService.GetAllAsync(); + var entities = (await _entityService.GetAllAsync()) + .GroupBy(e => e.UserId).ToDictionary(g => g.Key, g => g.First().Id); + var list = users.Select(u => new { - var n = q.Trim().ToLowerInvariant(); - items = items.Where(kv => kv.Key.ToLowerInvariant().Contains(n) || (kv.Value.Name ?? "").ToLowerInvariant().Contains(n)); - } - var cats = all.Values.Select(v => v.Category).Distinct().OrderBy(x => x).ToList(); - var list = items.OrderBy(kv => kv.Value.Category).ThenBy(kv => kv.Value.Name).Take(1000).ToList(); + name = string.IsNullOrEmpty(u.DisplayName) ? u.Id : u.DisplayName, + id = u.Id, + entity = entities.TryGetValue(u.Id, out var e) ? e : "", + auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : "" + }); + return Json(list); + } - var sb = new StringBuilder(); - sb.Append($"

Référence dataminée (TCF-Wiki) — {all.Count} entrées connues.

"); - sb.Append($""); - sb.Append(""); - sb.Append($"

{list.Count} résultat(s){(list.Count >= 1000 ? " (tronqué)" : "")}

"); - sb.Append("
CléTaille
{Esc(kv.Key)}{kv.Value?.Length ?? 0} car
"); - foreach (var kv in list) - sb.Append($""); - sb.Append("
NomRaretéCatégorieid
{Esc(kv.Value.Name)}{Rarity(kv.Value.Rarity)}{Esc(kv.Value.Category)}{Esc(kv.Key)}
"); - return Html(Layout("Catalogue", sb.ToString())); + [HttpGet("api/catalog")] + public IActionResult ApiCatalog() + { + if (!IsLan()) return Denied(); + var list = _gameRef.All.Select(kv => new + { + id = kv.Key, + name = kv.Value.Name, + rarity = kv.Value.Rarity, + category = kv.Value.Category + }); + return Json(list); } // ---------- helpers ---------- @@ -262,13 +250,12 @@ public class AdminController : ControllerBase if (ip.AddressFamily == AddressFamily.InterNetwork) { var b = ip.GetAddressBytes(); - if (b[0] == 10) return true; // 10.0.0.0/8 - if (b[0] == 192 && b[1] == 168) return true; // 192.168.0.0/16 - if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; // 172.16.0.0/12 + if (b[0] == 10) return true; + if (b[0] == 192 && b[1] == 168) return true; + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; if (b[0] == 127) return true; return false; } - // IPv6 unique-local (fc00::/7) or link-local (fe80::/10) var s = ip.GetAddressBytes(); if ((s[0] & 0xFE) == 0xFC) return true; if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true; @@ -280,6 +267,9 @@ public class AdminController : ControllerBase private IActionResult Html(string html) => Content(html, "text/html; charset=utf-8"); + private IActionResult Json(object data) => + Content(JsonSerializer.Serialize(data), "application/json; charset=utf-8"); + private static string Esc(string? s) => WebUtility.HtmlEncode(s ?? ""); private static string Pretty(string? json) @@ -333,7 +323,6 @@ public class AdminController : ControllerBase catch { return $"
{Esc(Pretty(json))}
"; } } - // Small coloured rarity chip. private static string Rarity(string? r) { if (string.IsNullOrEmpty(r)) return ""; @@ -352,31 +341,88 @@ public class AdminController : ControllerBase private static string Card(string label, string value) => $"
{Esc(value)}
{Esc(label)}
"; - private static string Layout(string title, string body) => $@" + private static string Layout(string title, string body) => + Head.Replace("__TITLE__", Esc(title)) + body + Foot; + + // Static shell (no C# interpolation → JS braces/quotes stay literal). Single quotes only. + private const string Head = @" -{Esc(title)} · The Cycle Admin +__TITLE__ · The Cycle Admin
The Cycle · Admin DashboardJoueursCatalogueTitleData read-only · LAN
-
{body}
"; +
"; + + private const string Foot = @"
+"; }