Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/* Admin console stylesheet. Deliberately small and dependency-free: the console
|
||||
is an internal operator tool served under /_gm, not a public surface. */
|
||||
:root {
|
||||
--bg: #11151c;
|
||||
--panel: #1b2230;
|
||||
--panel-hi: #232c3d;
|
||||
--ink: #e6ebf2;
|
||||
--ink-dim: #9aa7ba;
|
||||
--line: #2c3850;
|
||||
--accent: #5aa9ff;
|
||||
--danger: #ff6b6b;
|
||||
--ok: #4ecb8d;
|
||||
--warn: #f1c453;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
|
||||
.topbar .mainnav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
|
||||
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
|
||||
.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
|
||||
.lede { color: var(--ink-dim); margin-top: 0; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1.2rem 0; }
|
||||
.card {
|
||||
display: block;
|
||||
padding: 1rem 1.2rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.card:hover { background: var(--panel-hi); text-decoration: none; }
|
||||
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
|
||||
.card .bignum { font-size: 1.8rem; margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.panel {
|
||||
padding: 0.9rem 1.1rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
|
||||
.kv { list-style: none; margin: 0; padding: 0; }
|
||||
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
|
||||
.kv li b { color: var(--ink); font-weight: 600; }
|
||||
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
|
||||
.ok { color: var(--ok); }
|
||||
.bad { color: var(--danger); }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
|
||||
.list th { color: var(--ink-dim); font-weight: 600; }
|
||||
.list tr:hover td { background: var(--panel-hi); }
|
||||
.list td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
|
||||
.subnav { color: var(--ink-dim); margin: -0.2rem 0 1rem; font-size: 0.9rem; }
|
||||
.subnav a.active { color: var(--ink); }
|
||||
|
||||
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
|
||||
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
|
||||
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
|
||||
.form input, .form select, .form textarea {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
.form textarea { min-height: 4rem; resize: vertical; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #06121f;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger { background: var(--danger); color: #1a0606; }
|
||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||
.actions form { margin: 0; }
|
||||
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package adminconsole renders the backend's server-side admin console: a small,
|
||||
// dependency-free set of Go html/template pages plus one embedded stylesheet,
|
||||
// served under /_gm. It owns the rendering and the page view models only; the gin
|
||||
// handlers (internal/server) fetch the domain data, populate the view models and
|
||||
// gate the surface — the gateway puts HTTP Basic-Auth in front of /_gm and a
|
||||
// same-origin check guards the POST actions (docs/ARCHITECTURE.md §12). It mirrors
|
||||
// the shape of galaxy-game's adminconsole package, minus the per-operator CSRF
|
||||
// token and operator name (this console tracks no operator identity).
|
||||
package adminconsole
|
||||
@@ -0,0 +1,101 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Renderer holds the parsed admin console templates. It composes one template set
|
||||
// per content page, each combining the shared layout (the page chrome and the
|
||||
// "layout" entry template) with that page's "content" block, so rendering a page
|
||||
// is a single ExecuteTemplate call against "layout".
|
||||
type Renderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// PageData is the view model passed to every admin console page. Title is the
|
||||
// document title; ActiveNav marks the highlighted navigation entry; Data carries
|
||||
// the page-specific payload (one of the *View types in views.go).
|
||||
type PageData struct {
|
||||
Title string
|
||||
ActiveNav string
|
||||
Data any
|
||||
}
|
||||
|
||||
// NewRenderer parses the embedded layout and every content page under
|
||||
// templates/pages. It fails when a template cannot be parsed.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
||||
}
|
||||
|
||||
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
||||
}
|
||||
if len(pageFiles) == 0 {
|
||||
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
||||
}
|
||||
|
||||
pages := make(map[string]*template.Template, len(pageFiles))
|
||||
for _, file := range pageFiles {
|
||||
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
||||
clone, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
||||
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
||||
}
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
return &Renderer{pages: pages}, nil
|
||||
}
|
||||
|
||||
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
||||
// embedded at build time, so a parse failure is a programmer error.
|
||||
func MustNewRenderer() *Renderer {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
// Render writes the named page, wrapped in the shared layout, to w using data. It
|
||||
// renders into an intermediate buffer first, so a mid-render failure never emits
|
||||
// a partial document. It returns an error for an unknown page or a failed render.
|
||||
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
||||
tmpl, ok := r.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin console: unknown page %q", page)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
return fmt.Errorf("render admin console page %q: %w", page, err)
|
||||
}
|
||||
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// Assets returns the embedded static asset tree rooted at the assets directory,
|
||||
// suitable for serving under /_gm/assets/.
|
||||
func Assets() (fs.FS, error) {
|
||||
return fs.Sub(assetsFS, "assets")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRendererRendersEveryPage parses the embedded templates and renders each
|
||||
// page with a representative view, asserting the page executes, carries the
|
||||
// shared layout chrome and shows a distinctive value.
|
||||
func TestRendererRendersEveryPage(t *testing.T) {
|
||||
r, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("new renderer: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
page string
|
||||
data any
|
||||
want string
|
||||
}{
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
|
||||
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
|
||||
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
|
||||
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
|
||||
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
|
||||
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
|
||||
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.page, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil {
|
||||
t.Fatalf("render %s: %v", tc.page, err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, tc.want) {
|
||||
t.Errorf("render %s: missing %q in output", tc.page, tc.want)
|
||||
}
|
||||
if !strings.Contains(out, "Scrabble · admin") {
|
||||
t.Errorf("render %s: missing layout chrome", tc.page)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRendererUnknownPage reports an error for a page that does not exist.
|
||||
func TestRendererUnknownPage(t *testing.T) {
|
||||
r := MustNewRenderer()
|
||||
if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil {
|
||||
t.Fatal("expected an error rendering an unknown page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssets confirms the stylesheet is embedded and reachable under the assets
|
||||
// root.
|
||||
func TestAssets(t *testing.T) {
|
||||
fsys, err := Assets()
|
||||
if err != nil {
|
||||
t.Fatalf("assets: %v", err)
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
||||
t.Errorf("console.css not embedded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "layout" -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{.Title}} · Scrabble admin</title>
|
||||
<link rel="stylesheet" href="/_gm/assets/console.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<span class="brand">Scrabble · admin</span>
|
||||
<nav class="mainnav">
|
||||
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
|
||||
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
|
||||
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
|
||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="content">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "content" -}}
|
||||
<h1>Broadcast</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Post to the game channel</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/broadcast">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<div><button type="submit">Post to channel</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Complaint: {{.Word}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/complaints">« complaints</a></nav>
|
||||
<section class="panel"><h2>Details</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Word</b> <code>{{.Word}}</code></li>
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
|
||||
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
|
||||
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
|
||||
<li><b>Filed</b> {{.CreatedAt}}</li>
|
||||
<li><b>Status</b> {{.Status}}</li>
|
||||
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
|
||||
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
|
||||
<label>Disposition
|
||||
<select name="disposition">
|
||||
<option value="reject">reject — dictionary is correct</option>
|
||||
<option value="accept_add">accept — add word to the dictionary</option>
|
||||
<option value="accept_remove">accept — remove word from the dictionary</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Note <textarea name="note"></textarea></label>
|
||||
<div><button type="submit">Resolve</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
<h1>Complaints</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
|
||||
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
|
||||
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
|
||||
<td>{{.Variant}}</td>
|
||||
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.Disposition}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
|
||||
{{with .Data}}
|
||||
<div class="cards">
|
||||
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</p></a>
|
||||
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
|
||||
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
|
||||
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
||||
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Dictionaries</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dictionary</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Resident versions</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Hot-reload a version</h2>
|
||||
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
||||
<form class="form" method="post" action="/_gm/dictionary/reload">
|
||||
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
||||
<div><button type="submit">Reload</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Changes}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||
<label>Mark applied for variant
|
||||
<select name="variant">
|
||||
<option value="english">english</option>
|
||||
<option value="russian_scrabble">russian_scrabble</option>
|
||||
<option value="erudit">erudit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
||||
<div><button type="submit">Mark applied</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Game {{.ID}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/games">« games</a></nav>
|
||||
<section class="panel"><h2>Summary</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||
<li><b>Players</b> {{.Players}}</li>
|
||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
<li><b>Updated</b> {{.UpdatedAt}}</li>
|
||||
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Seats</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Seats}}
|
||||
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "content" -}}
|
||||
<h1>Games</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
||||
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
|
||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p>{{.Body}}</p>
|
||||
<p><a href="{{.Back}}">« back</a></p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.DisplayName}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a></nav>
|
||||
<div class="cards">
|
||||
<section class="panel"><h2>Account</h2>
|
||||
<ul class="kv">
|
||||
<li><b>ID</b> {{.ID}}</li>
|
||||
<li><b>Language</b> {{.Language}}</li>
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
<ul class="kv">
|
||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||
</ul>
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel"><h2>Identities</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Identities}}
|
||||
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<div><button type="submit">Send to user</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Games}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "content" -}}
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,200 @@
|
||||
package adminconsole
|
||||
|
||||
// The *View types are the display models the gin handlers fill and the templates
|
||||
// render. Time values are pre-formatted to strings by the handlers so the
|
||||
// templates stay logic-free.
|
||||
|
||||
// Pager is the shared list pagination state.
|
||||
type Pager struct {
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// NewPager builds the pagination state for a 1-based page of pageSize over total
|
||||
// items.
|
||||
func NewPager(page, pageSize, total int) Pager {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
|
||||
p.HasPrev = page > 1
|
||||
p.HasNext = page*pageSize < total
|
||||
return p
|
||||
}
|
||||
|
||||
// VariantVersions lists the dictionary versions resident for one variant.
|
||||
type VariantVersions struct {
|
||||
Variant string
|
||||
Latest string
|
||||
Versions []string
|
||||
}
|
||||
|
||||
// DashboardView is the landing-page summary.
|
||||
type DashboardView struct {
|
||||
Accounts int
|
||||
Games int
|
||||
ActiveGames int
|
||||
OpenComplaints int
|
||||
PendingChanges int
|
||||
Variants []VariantVersions
|
||||
}
|
||||
|
||||
// UsersView is the paginated account list.
|
||||
type UsersView struct {
|
||||
Items []UserRow
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// UserRow is one account row in the list.
|
||||
type UserRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Kind string
|
||||
Language string
|
||||
Guest bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// UserDetailView is one account with its stats, identities and recent games.
|
||||
type UserDetailView struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
Language string
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
HintBalance int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
}
|
||||
|
||||
// StatsRow is an account's lifetime statistics.
|
||||
type StatsRow struct {
|
||||
Wins int
|
||||
Losses int
|
||||
Draws int
|
||||
MaxGamePoints int
|
||||
MaxWordPoints int
|
||||
}
|
||||
|
||||
// IdentityRow is one platform/email identity of an account.
|
||||
type IdentityRow struct {
|
||||
Kind string
|
||||
ExternalID string
|
||||
Confirmed bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GameRow is one game row in a list.
|
||||
type GameRow struct {
|
||||
ID string
|
||||
Variant string
|
||||
Status string
|
||||
Players int
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// GamesView is the paginated games list, optionally filtered by status.
|
||||
type GamesView struct {
|
||||
Items []GameRow
|
||||
Status string
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// GameDetailView is one game with its seats.
|
||||
type GameDetailView struct {
|
||||
ID string
|
||||
Variant string
|
||||
DictVersion string
|
||||
Status string
|
||||
Players int
|
||||
ToMove int
|
||||
EndReason string
|
||||
MoveCount int
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
FinishedAt string
|
||||
Seats []SeatRow
|
||||
}
|
||||
|
||||
// SeatRow is one seat of a game.
|
||||
type SeatRow struct {
|
||||
Seat int
|
||||
DisplayName string
|
||||
AccountID string
|
||||
Score int
|
||||
HintsUsed int
|
||||
Winner bool
|
||||
}
|
||||
|
||||
// ComplaintsView is the paginated complaint review queue.
|
||||
type ComplaintsView struct {
|
||||
Items []ComplaintRow
|
||||
Status string
|
||||
Pager Pager
|
||||
}
|
||||
|
||||
// ComplaintRow is one complaint row in the queue.
|
||||
type ComplaintRow struct {
|
||||
ID string
|
||||
Word string
|
||||
Variant string
|
||||
WasValid bool
|
||||
Status string
|
||||
Disposition string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// ComplaintDetailView is one complaint with its resolution state and form.
|
||||
type ComplaintDetailView struct {
|
||||
ID string
|
||||
Word string
|
||||
Variant string
|
||||
DictVersion string
|
||||
WasValid bool
|
||||
Note string
|
||||
Status string
|
||||
Disposition string
|
||||
ResolutionNote string
|
||||
CreatedAt string
|
||||
ResolvedAt string
|
||||
GameID string
|
||||
Resolved bool
|
||||
}
|
||||
|
||||
// DictionaryView lists the resident versions per variant and the pending
|
||||
// wordlist changes from accepted complaints.
|
||||
type DictionaryView struct {
|
||||
Variants []VariantVersions
|
||||
Changes []DictChangeRow
|
||||
}
|
||||
|
||||
// DictChangeRow is one pending wordlist edit.
|
||||
type DictChangeRow struct {
|
||||
Variant string
|
||||
Word string
|
||||
Action string
|
||||
ResolvedAt string
|
||||
}
|
||||
|
||||
// BroadcastView is the operator-broadcast form page.
|
||||
type BroadcastView struct {
|
||||
ConnectorEnabled bool
|
||||
}
|
||||
|
||||
// MessageView is the result page shown after a POST action.
|
||||
type MessageView struct {
|
||||
Heading string
|
||||
Body string
|
||||
Back string
|
||||
}
|
||||
Reference in New Issue
Block a user