Add stats, profiles, avatars and palaute

Step 6. The surfaces around the review loop.

- Nine leaderboards, ordered and limited in SQL, each with a deterministic
  tie-break so a tied board doesn't reshuffle between reloads. Min 3 reviews
  to qualify, for reviewer boards too
- Profiles show counts and history-wide averages and the member's songs,
  never a list of their reviews — per-song opinion stays gated
- Avatars: 5MB in, 256px JPEG out, ffmpeg's re-encode being the validation.
  No upload still means initials, and avatars are public
- Changing your own password requires the current one and drops your other
  sessions
- Palaute: free text plus the page you were on, carried in a footer link, and
  the user agent from the header. Reporters see their own; the admin resolves
  them with a timestamp rather than a status enum
- Admin gained the song list with delete, the reports page, and an open-report
  count on the dashboard

Two theme fixes the screenshots caught: leaderboard ranks need a CSS counter
because display:grid suppresses list markers, and count-based boards were
printing 3.0 where they mean 3.
This commit is contained in:
Esa Kataja
2026-07-31 22:34:04 +03:00
parent f33f4fa4d6
commit f1e907bac3
16 changed files with 991 additions and 6 deletions
+30
View File
@@ -1,5 +1,6 @@
{{define "content"}}
<h1>Ylläpito</h1>
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
<section class="adminsection">
<header>
@@ -59,4 +60,33 @@
</table>
</div>
</section>
<section class="adminsection">
<header><h2>Kappaleet</h2></header>
<div class="body">
<table>
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
<tbody>
{{range .Data.Songs}}
<tr>
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
<td>{{.Submitter}}</td>
<td>{{.Reviews}}</td>
<td class="nowrap">{{fidate .CreatedAt}}</td>
<td class="actions">
<a href="/admin/audio/{{.ID}}">Kuuntele</a>
<form method="post" action="/admin/songs/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
<button type="submit" class="ghost danger">Poista</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<h1>Palautteet</h1>
<p><a href="/admin">← Ylläpito</a></p>
{{range .Data}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="who">{{.Reporter}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
<p class="meta break">{{.UserAgent}}</p>
{{if .Open}}
<form method="post" action="/admin/reports/{{.ID}}/resolve">
<button type="submit" class="ghost">Merkitse käsitellyksi</button>
</form>
{{end}}
</article>
{{else}}
<p class="empty">Ei palautteita.</p>
{{end}}
{{end}}
+14 -2
View File
@@ -22,13 +22,17 @@
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono</a>
<a href="/songs" {{if eq .Path "/songs"}}aria-current="page"{{end}}>Kappaleet</a>
<a href="/submit" {{if eq .Path "/submit"}}aria-current="page"{{end}}>Lähetä</a>
<a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
</nav>
<div class="userblock">
<span class="lines">
<span class="name">{{.Member.Name}}</span>
<span class="email">{{.Member.Email}}</span>
</span>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
<a href="/profile" title="{{.Member.Name}}">
{{if .Member.Avatar}}<img class="avatar" src="/avatars/{{.Member.ID}}" alt="Oma profiili">
{{else}}<span class="avatar">{{.Member.Initials}}</span>{{end}}
</a>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
<!-- <details> is the mobile panel: no JS, and Esc/click-away come free. -->
@@ -38,6 +42,8 @@
<a href="/">Jono</a>
<a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a>
<a href="/profile">Oma profiili</a>
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
</div>
</details>
@@ -51,7 +57,13 @@
<main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
<footer class="sitefooter">Levyraati — kymmenen kaverin levyraati.</footer>
<footer class="sitefooter">
{{if .Member}}
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}}
Levyraati — kymmenen kaverin levyraati.
</footer>
{{with .Flash}}
<div class="toasts">
+45
View File
@@ -0,0 +1,45 @@
{{define "content"}}
{{$p := .Data}}
<div class="profilehead">
{{if $p.Avatar}}
<img class="avatar big" src="/avatars/{{$p.ID}}" alt="">
{{else}}
<span class="avatar big">{{$p.Initials}}</span>
{{end}}
<div>
<h1>{{$p.Name}}</h1>
<p class="muted">Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}</p>
</div>
</div>
<div class="statgrid">
<div class="statcard"><span class="statvalue">{{$p.Stats.SongsSubmitted}}</span><span class="muted small">kappaletta</span></div>
<div class="statcard"><span class="statvalue">{{$p.Stats.ReviewsWritten}}</span><span class="muted small">arvostelua</span></div>
<div class="statcard"><span class="statvalue">{{if $p.Stats.AverageGiven}}{{score $p.Stats.AverageGiven}}{{else}}—{{end}}</span><span class="muted small">antanut ka.</span></div>
<div class="statcard"><span class="statvalue">{{if $p.Stats.AverageReceived}}{{score $p.Stats.AverageReceived}}{{else}}—{{end}}</span><span class="muted small">saanut ka.</span></div>
</div>
{{if $p.Own}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
<form method="post" action="/profile" enctype="multipart/form-data" class="stack">
<label>Nimi <input name="name" value="{{$p.Name}}" maxlength="50" required></label>
<label>Sähköposti <input type="email" name="email" value="{{$p.Email}}" required></label>
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
<button type="submit">Tallenna</button>
</form>
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
</details>
{{end}}
<section>
<h2>Kappaleet</h2>
{{if $p.Songs}}
<div class="songgrid">{{range $p.Songs}}{{template "songcard" .}}{{end}}</div>
{{else}}
<p class="muted">Ei vielä yhtään kappaletta.</p>
{{end}}
</section>
{{end}}
+30
View File
@@ -0,0 +1,30 @@
{{define "content"}}
<h1>Palaute</h1>
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — yksi
virke riittää.</p>
<form method="post" action="/report" class="stack">
<input type="hidden" name="from" value="{{.Data.From}}">
<label>Palaute
<textarea name="body" rows="6" maxlength="2000" required autofocus
placeholder="Esim. soitin ei toimi puhelimella."></textarea>
</label>
<button type="submit">Lähetä palaute</button>
</form>
<p class="muted small">Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.</p>
{{with .Data.Mine}}
<section>
<h2>Omat palautteet</h2>
{{range .}}
<article class="review{{if .Open}} own{{end}}">
<header>
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
</header>
<p>{{.Body}}</p>
</article>
{{end}}
</section>
{{end}}
{{end}}
+56
View File
@@ -0,0 +1,56 @@
{{define "songboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/songs/{{.ID}}">{{.Title}}</a>
<span class="muted small">{{.Artist}}</span>
<span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.ReviewCount}} arv.</span>{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "userboard"}}
<section class="board">
<h2>{{.Title}}</h2>
{{if .Items}}
<ol class="board-list">
{{range .Items}}
<li>
<a href="/profile/{{.ID}}">{{.Name}}</a>
<span class="value">{{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.Count}} kpl</span>{{end}}
</li>
{{end}}
</ol>
{{else}}
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
{{end}}
</section>
{{end}}
{{define "content"}}
<h1>Tilastot</h1>
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
Tilastot näkyvät kaikille — täällä pisteitä ei piiloteta.</p>
<div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
{{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}}
{{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive}}
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified}}
{{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" true}}
{{template "userboard" dict "Title" "Ankarin arvostelija" "Items" .Data.Harshest}}
{{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}}
{{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}}
{{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}}
</div>
{{end}}