Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f3fd1125 | ||
|
|
b5c295f45c |
@@ -14,6 +14,8 @@ namespace Prospect.Server.Api.Controllers;
|
|||||||
// Read-only back-office to visualise everything relevant in the database.
|
// 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
|
// 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).
|
// 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")]
|
[Route("admin")]
|
||||||
public class AdminController : ControllerBase
|
public class AdminController : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -51,58 +53,45 @@ public class AdminController : ControllerBase
|
|||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append("<div class='cards'>");
|
sb.Append("<div class='cards'>");
|
||||||
sb.Append(Card("Joueurs", users.Count.ToString()));
|
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("Docs UserData", dataCount.ToString()));
|
||||||
sb.Append(Card("Clés TitleData", titleKeys.Count.ToString()));
|
sb.Append(Card("Clés TitleData", titleKeys.Count.ToString()));
|
||||||
|
sb.Append(Card("Catalogue", _gameRef.All.Count.ToString()));
|
||||||
sb.Append("</div>");
|
sb.Append("</div>");
|
||||||
|
sb.Append("<p><a class='btn' href='/admin/players'>Joueurs →</a> <a class='btn' href='/admin/catalog'>Catalogue →</a> <a class='btn' href='/admin/titledata'>TitleData →</a></p>");
|
||||||
sb.Append("<h2>Joueurs récents</h2>");
|
|
||||||
sb.Append("<table><thead><tr><th>DisplayName</th><th>PlayFab Id</th><th>Auth</th><th></th></tr></thead><tbody>");
|
|
||||||
foreach (var u in users.Take(15))
|
|
||||||
{
|
|
||||||
var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
|
|
||||||
sb.Append($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(auth)}</td>" +
|
|
||||||
$"<td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
|
|
||||||
}
|
|
||||||
sb.Append("</tbody></table>");
|
|
||||||
sb.Append("<p><a class='btn' href='/admin/players'>Tous les joueurs →</a> <a class='btn' href='/admin/titledata'>TitleData →</a></p>");
|
|
||||||
|
|
||||||
return Html(Layout("Dashboard", sb.ToString()));
|
return Html(Layout("Dashboard", sb.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("players")]
|
[HttpGet("players")]
|
||||||
public async Task<IActionResult> Players([FromQuery] string? q)
|
public IActionResult Players()
|
||||||
{
|
{
|
||||||
if (!IsLan()) return Denied();
|
if (!IsLan()) return Denied();
|
||||||
|
var body =
|
||||||
|
"<h2>Joueurs</h2>" +
|
||||||
|
"<p><a class='btn' href='/admin/backfill-names' title='Récupère les pseudos Steam des comptes sans nom'>⟳ Backfill pseudos Steam</a></p>" +
|
||||||
|
"<div id='tbl'></div>" +
|
||||||
|
"<script>DT.init({id:'tbl',url:'/admin/api/players',size:25,filters:['auth'],columns:[" +
|
||||||
|
"{key:'name',label:'Nom',kind:'link',base:'/admin/players/',lkey:'id'}," +
|
||||||
|
"{key:'id',label:'PlayFab Id',kind:'mono'}," +
|
||||||
|
"{key:'entity',label:'Entity Id',kind:'mono'}," +
|
||||||
|
"{key:'auth',label:'Auth',kind:'mono'}]});</script>";
|
||||||
|
return Html(Layout("Joueurs", body));
|
||||||
|
}
|
||||||
|
|
||||||
var users = await _userService.GetAllAsync();
|
[HttpGet("catalog")]
|
||||||
var entities = (await _entityService.GetAllAsync())
|
public IActionResult Catalog()
|
||||||
.GroupBy(e => e.UserId)
|
{
|
||||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
if (!IsLan()) return Denied();
|
||||||
|
var body =
|
||||||
if (!string.IsNullOrWhiteSpace(q))
|
"<h2>Catalogue</h2>" +
|
||||||
{
|
"<p class='muted'>Référence dataminée (TCF-Wiki) — " + _gameRef.All.Count + " entrées.</p>" +
|
||||||
var needle = q.Trim().ToLowerInvariant();
|
"<div id='tbl'></div>" +
|
||||||
users = users.Where(u =>
|
"<script>DT.init({id:'tbl',url:'/admin/api/catalog',size:50,filters:['category','rarity'],columns:[" +
|
||||||
(u.DisplayName ?? "").ToLowerInvariant().Contains(needle) ||
|
"{key:'name',label:'Nom'}," +
|
||||||
(u.Id ?? "").ToLowerInvariant().Contains(needle) ||
|
"{key:'rarity',label:'Rareté',kind:'rarity'}," +
|
||||||
(u.Auth != null && u.Auth.Any(a => (a.Key ?? "").ToLowerInvariant().Contains(needle)))).ToList();
|
"{key:'category',label:'Catégorie'}," +
|
||||||
}
|
"{key:'id',label:'id',kind:'mono'}]});</script>";
|
||||||
|
return Html(Layout("Catalogue", body));
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.Append($"<form method='get' class='search'><input name='q' placeholder='Rechercher nom / id / steam…' value='{Esc(q ?? "")}'/><button class='btn'>Rechercher</button>" +
|
|
||||||
"<a class='btn' href='/admin/backfill-names' title='Récupère les pseudos Steam des comptes sans nom'>Backfill pseudos Steam</a></form>");
|
|
||||||
sb.Append($"<p class='muted'>{users.Count} joueur(s)</p>");
|
|
||||||
sb.Append("<table><thead><tr><th>DisplayName</th><th>PlayFab Id</th><th>Entity Id</th><th>Auth</th><th></th></tr></thead><tbody>");
|
|
||||||
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($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(ent)}</td>" +
|
|
||||||
$"<td class='mono small'>{Esc(auth)}</td><td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
|
|
||||||
}
|
|
||||||
sb.Append("</tbody></table>");
|
|
||||||
return Html(Layout("Joueurs", sb.ToString()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("players/{id}")]
|
[HttpGet("players/{id}")]
|
||||||
@@ -119,10 +108,10 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
|
var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
|
||||||
|
sb.Append($"<p><a class='lnk' href='/admin/players'>← Joueurs</a></p>");
|
||||||
sb.Append($"<h2>{Esc(user.DisplayName)}</h2>");
|
sb.Append($"<h2>{Esc(user.DisplayName)}</h2>");
|
||||||
sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>");
|
sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>");
|
||||||
|
|
||||||
// Summary cards
|
|
||||||
sb.Append("<div class='cards'>");
|
sb.Append("<div class='cards'>");
|
||||||
if (map.TryGetValue("Balance", out var bal))
|
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(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim()));
|
||||||
sb.Append("</div>");
|
sb.Append("</div>");
|
||||||
|
|
||||||
// Inventory table
|
|
||||||
if (map.TryGetValue("Inventory", out var inv))
|
if (map.TryGetValue("Inventory", out var inv))
|
||||||
{
|
{
|
||||||
sb.Append("<h3>Inventaire</h3>");
|
sb.Append("<h3>Inventaire</h3>");
|
||||||
sb.Append(InventoryTable(inv.Value));
|
sb.Append(InventoryTable(inv.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
// All keys
|
|
||||||
sb.Append("<h3>Toutes les données</h3>");
|
sb.Append("<h3>Toutes les données</h3>");
|
||||||
foreach (var d in data)
|
foreach (var d in data)
|
||||||
{
|
{
|
||||||
@@ -153,7 +140,6 @@ public class AdminController : ControllerBase
|
|||||||
$" <span class='muted small'>· {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}</span></summary>");
|
$" <span class='muted small'>· {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}</span></summary>");
|
||||||
sb.Append($"<pre>{Esc(Pretty(d.Value))}</pre></details>");
|
sb.Append($"<pre>{Esc(Pretty(d.Value))}</pre></details>");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Html(Layout("Joueur · " + user.DisplayName, sb.ToString()));
|
return Html(Layout("Joueur · " + user.DisplayName, sb.ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +150,7 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
var all = _titleDataService.Find(new List<string>());
|
var all = _titleDataService.Find(new List<string>());
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("<h2>TitleData</h2>");
|
||||||
sb.Append("<table><thead><tr><th>Clé</th><th>Taille</th><th></th></tr></thead><tbody>");
|
sb.Append("<table><thead><tr><th>Clé</th><th>Taille</th><th></th></tr></thead><tbody>");
|
||||||
foreach (var kv in all.OrderBy(k => k.Key))
|
foreach (var kv in all.OrderBy(k => k.Key))
|
||||||
sb.Append($"<tr><td class='mono'>{Esc(kv.Key)}</td><td>{kv.Value?.Length ?? 0} car</td>" +
|
sb.Append($"<tr><td class='mono'>{Esc(kv.Key)}</td><td>{kv.Value?.Length ?? 0} car</td>" +
|
||||||
@@ -181,11 +168,10 @@ public class AdminController : ControllerBase
|
|||||||
if (!all.TryGetValue(key, out var value))
|
if (!all.TryGetValue(key, out var value))
|
||||||
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>"));
|
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>"));
|
||||||
|
|
||||||
// Large values (Blueprints, Contracts…) can be huge — pretty-print only if reasonable.
|
|
||||||
var body = value != null && value.Length > 300_000
|
var body = value != null && value.Length > 300_000
|
||||||
? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>"
|
? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>"
|
||||||
: $"<pre>{Esc(Pretty(value))}</pre>";
|
: $"<pre>{Esc(Pretty(value))}</pre>";
|
||||||
return Html(Layout("TitleData · " + key, $"<h2 class='mono'>{Esc(key)}</h2>" + body));
|
return Html(Layout("TitleData · " + key, $"<p><a class='lnk' href='/admin/titledata'>← TitleData</a></p><h2 class='mono'>{Esc(key)}</h2>" + body));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("backfill-names")]
|
[HttpGet("backfill-names")]
|
||||||
@@ -220,35 +206,37 @@ public class AdminController : ControllerBase
|
|||||||
return Html(Layout("Backfill", body));
|
return Html(Layout("Backfill", body));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("catalog")]
|
// ---------- JSON API (consumed by the client-side table engine) ----------
|
||||||
public IActionResult Catalog([FromQuery] string? q, [FromQuery] string? cat)
|
|
||||||
|
[HttpGet("api/players")]
|
||||||
|
public async Task<IActionResult> ApiPlayers()
|
||||||
{
|
{
|
||||||
if (!IsLan()) return Denied();
|
if (!IsLan()) return Denied();
|
||||||
|
var users = await _userService.GetAllAsync();
|
||||||
var all = _gameRef.All;
|
var entities = (await _entityService.GetAllAsync())
|
||||||
IEnumerable<KeyValuePair<string, GameRefEntry>> items = all;
|
.GroupBy(e => e.UserId).ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
if (!string.IsNullOrWhiteSpace(cat))
|
var list = users.Select(u => new
|
||||||
items = items.Where(kv => kv.Value.Category == cat);
|
|
||||||
if (!string.IsNullOrWhiteSpace(q))
|
|
||||||
{
|
{
|
||||||
var n = q.Trim().ToLowerInvariant();
|
name = string.IsNullOrEmpty(u.DisplayName) ? u.Id : u.DisplayName,
|
||||||
items = items.Where(kv => kv.Key.ToLowerInvariant().Contains(n) || (kv.Value.Name ?? "").ToLowerInvariant().Contains(n));
|
id = u.Id,
|
||||||
}
|
entity = entities.TryGetValue(u.Id, out var e) ? e : "",
|
||||||
var cats = all.Values.Select(v => v.Category).Distinct().OrderBy(x => x).ToList();
|
auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""
|
||||||
var list = items.OrderBy(kv => kv.Value.Category).ThenBy(kv => kv.Value.Name).Take(1000).ToList();
|
});
|
||||||
|
return Json(list);
|
||||||
|
}
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
[HttpGet("api/catalog")]
|
||||||
sb.Append($"<p class='muted'>Référence dataminée (TCF-Wiki) — {all.Count} entrées connues.</p>");
|
public IActionResult ApiCatalog()
|
||||||
sb.Append($"<form method='get' class='search'><input name='q' placeholder='nom / id…' value='{Esc(q ?? "")}'/>");
|
{
|
||||||
sb.Append("<select name='cat'><option value=''>(toutes)</option>");
|
if (!IsLan()) return Denied();
|
||||||
foreach (var c in cats) sb.Append($"<option value='{Esc(c)}'{(c == cat ? " selected" : "")}>{Esc(c)}</option>");
|
var list = _gameRef.All.Select(kv => new
|
||||||
sb.Append("</select><button class='btn'>Filtrer</button></form>");
|
{
|
||||||
sb.Append($"<p class='muted'>{list.Count} résultat(s){(list.Count >= 1000 ? " (tronqué)" : "")}</p>");
|
id = kv.Key,
|
||||||
sb.Append("<table><thead><tr><th>Nom</th><th>Rareté</th><th>Catégorie</th><th>id</th></tr></thead><tbody>");
|
name = kv.Value.Name,
|
||||||
foreach (var kv in list)
|
rarity = kv.Value.Rarity,
|
||||||
sb.Append($"<tr><td>{Esc(kv.Value.Name)}</td><td>{Rarity(kv.Value.Rarity)}</td><td>{Esc(kv.Value.Category)}</td><td class='mono small'>{Esc(kv.Key)}</td></tr>");
|
category = kv.Value.Category
|
||||||
sb.Append("</tbody></table>");
|
});
|
||||||
return Html(Layout("Catalogue", sb.ToString()));
|
return Json(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- helpers ----------
|
// ---------- helpers ----------
|
||||||
@@ -262,13 +250,12 @@ public class AdminController : ControllerBase
|
|||||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||||
{
|
{
|
||||||
var b = ip.GetAddressBytes();
|
var b = ip.GetAddressBytes();
|
||||||
if (b[0] == 10) return true; // 10.0.0.0/8
|
if (b[0] == 10) return true;
|
||||||
if (b[0] == 192 && b[1] == 168) return true; // 192.168.0.0/16
|
if (b[0] == 192 && b[1] == 168) return true;
|
||||||
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; // 172.16.0.0/12
|
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true;
|
||||||
if (b[0] == 127) return true;
|
if (b[0] == 127) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// IPv6 unique-local (fc00::/7) or link-local (fe80::/10)
|
|
||||||
var s = ip.GetAddressBytes();
|
var s = ip.GetAddressBytes();
|
||||||
if ((s[0] & 0xFE) == 0xFC) return true;
|
if ((s[0] & 0xFE) == 0xFC) return true;
|
||||||
if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) 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 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 Esc(string? s) => WebUtility.HtmlEncode(s ?? "");
|
||||||
|
|
||||||
private static string Pretty(string? json)
|
private static string Pretty(string? json)
|
||||||
@@ -333,7 +323,6 @@ public class AdminController : ControllerBase
|
|||||||
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
|
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small coloured rarity chip.
|
|
||||||
private static string Rarity(string? r)
|
private static string Rarity(string? r)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(r)) return "";
|
if (string.IsNullOrEmpty(r)) return "";
|
||||||
@@ -352,31 +341,88 @@ public class AdminController : ControllerBase
|
|||||||
private static string Card(string label, string value) =>
|
private static string Card(string label, string value) =>
|
||||||
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
|
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
|
||||||
|
|
||||||
private static string Layout(string title, string body) => $@"<!doctype html><html lang='fr'><head>
|
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 = @"<!doctype html><html lang='fr'><head>
|
||||||
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
|
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||||
<title>{Esc(title)} · The Cycle Admin</title>
|
<title>__TITLE__ · The Cycle Admin</title>
|
||||||
<style>
|
<style>
|
||||||
:root{{--bg:#0f1115;--panel:#171a21;--line:#262b36;--txt:#e6e8ec;--muted:#8b93a1;--acc:#e8a838;}}
|
:root{--bg:#0f1115;--panel:#171a21;--line:#262b36;--txt:#e6e8ec;--muted:#8b93a1;--acc:#e8a838;}
|
||||||
*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif}}
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif}
|
||||||
header{{background:var(--panel);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;gap:18px;align-items:center;position:sticky;top:0}}
|
header{background:var(--panel);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;gap:18px;align-items:center;position:sticky;top:0;z-index:10}
|
||||||
header b{{color:var(--acc)}}header a{{color:var(--muted);text-decoration:none}}header a:hover{{color:var(--txt)}}
|
header b{color:var(--acc)}header a{color:var(--muted);text-decoration:none}header a:hover{color:var(--txt)}
|
||||||
main{{padding:20px;max-width:1200px;margin:0 auto}}
|
main{padding:20px;max-width:1200px;margin:0 auto}
|
||||||
h2{{margin:18px 0 10px}}h3{{margin:22px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}}
|
h2{margin:18px 0 10px}h3{margin:22px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}
|
||||||
.cards{{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}}
|
.cards{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}
|
||||||
.card{{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:130px}}
|
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:130px}
|
||||||
.card .v{{font-size:22px;font-weight:700;color:var(--acc)}}.card .l{{color:var(--muted);font-size:12px}}
|
.card .v{font-size:22px;font-weight:700;color:var(--acc)}.card .l{color:var(--muted);font-size:12px}
|
||||||
table{{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}}
|
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||||
th,td{{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:top}}
|
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
th{{background:#1c2029;color:var(--muted);font-weight:600}}tr:last-child td{{border-bottom:none}}
|
th{background:#1c2029;color:var(--muted);font-weight:600}tr:last-child td{border-bottom:none}
|
||||||
.mono{{font-family:ui-monospace,Consolas,monospace}}.small{{font-size:12px}}.muted{{color:var(--muted)}}
|
tbody tr:hover{background:#1b2028}
|
||||||
.btn{{display:inline-block;background:#222834;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:5px 12px;text-decoration:none;font-size:13px}}
|
.mono{font-family:ui-monospace,Consolas,monospace}.small{font-size:12px}.muted{color:var(--muted)}
|
||||||
.btn:hover{{border-color:var(--acc);color:var(--acc)}}
|
.btn{display:inline-block;background:#222834;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:5px 12px;text-decoration:none;font-size:13px;cursor:pointer}
|
||||||
.search{{display:flex;gap:8px;margin:12px 0}}.search input{{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:8px 12px}}
|
.btn:hover{border-color:var(--acc);color:var(--acc)}
|
||||||
details{{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin:6px 0}}summary{{cursor:pointer;padding:8px 12px}}
|
a.lnk{color:var(--acc);text-decoration:none}a.lnk:hover{text-decoration:underline}
|
||||||
pre{{margin:0;padding:12px;background:#0b0d11;border-top:1px solid var(--line);overflow:auto;max-height:480px;white-space:pre-wrap;word-break:break-word}}
|
details{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin:6px 0}summary{cursor:pointer;padding:8px 12px}
|
||||||
|
pre{margin:0;padding:12px;background:#0b0d11;border-top:1px solid var(--line);overflow:auto;max-height:480px;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.dtbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}
|
||||||
|
.dtbar input,.dtbar select{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:7px 10px}
|
||||||
|
.dtsearch{flex:1;min-width:220px}
|
||||||
|
.dtcount{margin-left:auto}
|
||||||
|
th.dth{cursor:pointer;user-select:none;white-space:nowrap}th.dth:hover{color:var(--txt)}
|
||||||
|
.dtfoot{display:flex;gap:12px;align-items:center;margin:10px 0}
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<header><b>The Cycle · Admin</b>
|
<header><b>The Cycle · Admin</b>
|
||||||
<a href='/admin'>Dashboard</a><a href='/admin/players'>Joueurs</a><a href='/admin/catalog'>Catalogue</a><a href='/admin/titledata'>TitleData</a>
|
<a href='/admin'>Dashboard</a><a href='/admin/players'>Joueurs</a><a href='/admin/catalog'>Catalogue</a><a href='/admin/titledata'>TitleData</a>
|
||||||
<span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
|
<span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
|
||||||
<main>{body}</main></body></html>";
|
<main>";
|
||||||
|
|
||||||
|
private const string Foot = @"</main>
|
||||||
|
<script>
|
||||||
|
const DT={
|
||||||
|
init(cfg){const el=document.getElementById(cfg.id);el.innerHTML='<p class=muted>Chargement…</p>';
|
||||||
|
fetch(cfg.url).then(r=>r.json()).then(rows=>DT.build(el,cfg,rows)).catch(e=>{el.innerHTML='<p class=muted>Erreur: '+e+'</p>';});},
|
||||||
|
build(el,cfg,rows){el.innerHTML='';el._rows=rows;el._cfg=cfg;el._sort={k:null,d:1};el._page=1;el._size=cfg.size||25;el._filters={};el._q='';
|
||||||
|
const bar=document.createElement('div');bar.className='dtbar';
|
||||||
|
const s=document.createElement('input');s.className='dtsearch';s.placeholder='Rechercher…';s.oninput=()=>{el._q=s.value.toLowerCase();el._page=1;DT.draw(el);};bar.appendChild(s);
|
||||||
|
(cfg.filters||[]).forEach(fk=>{const vals=[...new Set(rows.map(r=>r[fk]).filter(v=>v!=null&&v!==''))].sort();
|
||||||
|
const sel=document.createElement('select');const o0=document.createElement('option');o0.value='';o0.textContent=fk;sel.appendChild(o0);
|
||||||
|
vals.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;sel.appendChild(o);});
|
||||||
|
sel.onchange=()=>{el._filters[fk]=sel.value;el._page=1;DT.draw(el);};bar.appendChild(sel);});
|
||||||
|
const sz=document.createElement('select');[25,50,100,500].forEach(n=>{const o=document.createElement('option');o.value=n;o.textContent=n+'/page';sz.appendChild(o);});
|
||||||
|
const oa=document.createElement('option');oa.value='0';oa.textContent='Tout';sz.appendChild(oa);sz.value=el._size;
|
||||||
|
sz.onchange=()=>{el._size=+sz.value;el._page=1;DT.draw(el);};bar.appendChild(sz);
|
||||||
|
const cnt=document.createElement('span');cnt.className='dtcount muted';bar.appendChild(cnt);el._cnt=cnt;el.appendChild(bar);
|
||||||
|
const tbl=document.createElement('table');const th=document.createElement('thead');const tr=document.createElement('tr');
|
||||||
|
cfg.columns.forEach(c=>{const h=document.createElement('th');h.className='dth';h.dataset.k=c.key;h.textContent=c.label;h.onclick=()=>DT.sort(el,c.key);tr.appendChild(h);});
|
||||||
|
th.appendChild(tr);tbl.appendChild(th);el._head=tr;const tb=document.createElement('tbody');tbl.appendChild(tb);el._tb=tb;el.appendChild(tbl);
|
||||||
|
const ft=document.createElement('div');ft.className='dtfoot';
|
||||||
|
const pv=document.createElement('button');pv.className='btn';pv.textContent='‹ Préc.';pv.onclick=()=>{if(el._page>1){el._page--;DT.draw(el);}};
|
||||||
|
const nf=document.createElement('span');nf.className='muted';el._nfo=nf;
|
||||||
|
const nx=document.createElement('button');nx.className='btn';nx.textContent='Suiv. ›';nx.onclick=()=>{el._page++;DT.draw(el);};
|
||||||
|
ft.appendChild(pv);ft.appendChild(nf);ft.appendChild(nx);el.appendChild(ft);DT.draw(el);},
|
||||||
|
rows(el){const cfg=el._cfg;let rows=el._rows;
|
||||||
|
Object.keys(el._filters).forEach(k=>{const fv=el._filters[k];if(fv)rows=rows.filter(r=>(''+(r[k]??''))===fv);});
|
||||||
|
if(el._q)rows=rows.filter(r=>cfg.columns.some(c=>(''+(r[c.key]??'')).toLowerCase().includes(el._q)));
|
||||||
|
if(el._sort.k){const k=el._sort.k,d=el._sort.d;rows=[...rows].sort((a,b)=>{let x=a[k],y=b[k];const nx=parseFloat(x),ny=parseFloat(y);
|
||||||
|
if(!isNaN(nx)&&!isNaN(ny)&&(''+x).trim()!==''&&(''+y).trim()!==''){x=nx;y=ny;}else{x=(''+(x??'')).toLowerCase();y=(''+(y??'')).toLowerCase();}return x<y?-d:x>y?d:0;});}
|
||||||
|
return rows;},
|
||||||
|
sort(el,k){if(el._sort.k===k)el._sort.d*=-1;else{el._sort.k=k;el._sort.d=1;}el._page=1;DT.draw(el);},
|
||||||
|
draw(el){const cfg=el._cfg,rows=DT.rows(el),total=rows.length,size=el._size||total||1,pages=Math.max(1,Math.ceil(total/size));
|
||||||
|
if(el._page>pages)el._page=pages;const start=el._size?(el._page-1)*size:0;const slice=el._size?rows.slice(start,start+size):rows;
|
||||||
|
el._tb.innerHTML=slice.map(r=>'<tr>'+cfg.columns.map(c=>DT.cell(c,r)).join('')+'</tr>').join('')||'<tr><td colspan='+cfg.columns.length+' class=muted>Aucun résultat</td></tr>';
|
||||||
|
el._cnt.textContent=total+' résultat(s)';el._nfo.textContent='page '+el._page+'/'+pages;
|
||||||
|
[...el._head.children].forEach(h=>{const c=cfg.columns.find(x=>x.key===h.dataset.k);h.textContent=c.label+(el._sort.k===h.dataset.k?(el._sort.d>0?' ▲':' ▼'):'');});},
|
||||||
|
cell(c,r){let v=r[c.key];v=v==null?'':(''+v);
|
||||||
|
if(c.kind==='rarity')return '<td>'+DT.rarity(v)+'</td>';
|
||||||
|
if(c.kind==='link')return '<td><a class=lnk href='+c.base+encodeURIComponent(r[c.lkey||'id'])+'>'+DT.esc(v)+'</a></td>';
|
||||||
|
const cls=c.kind==='mono'?'mono small':'';return '<td class='+cls+'>'+DT.esc(v)+'</td>';},
|
||||||
|
rarity(r){if(!r)return '';const c={common:'#9aa4b2',uncommon:'#4caf50',rare:'#2f81f7',epic:'#a371f7',legendary:'#e8a838',exotic:'#e8a838'}[r.toLowerCase()]||'#9aa4b2';
|
||||||
|
return '<span style=color:'+c+';font-weight:600>'+DT.esc(r)+'</span>';},
|
||||||
|
esc(s){const d=document.createElement('div');d.textContent=(s==null?'':''+s);return d.innerHTML;}
|
||||||
|
};
|
||||||
|
</script></body></html>";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user