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:
@@ -29,8 +29,10 @@ type adminMember struct {
|
||||
}
|
||||
|
||||
type dashboard struct {
|
||||
Invites []adminInvite
|
||||
Members []adminMember
|
||||
Invites []adminInvite
|
||||
Members []adminMember
|
||||
Songs []adminSong
|
||||
OpenCount int
|
||||
}
|
||||
|
||||
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -77,6 +79,16 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
|
||||
adminError(w, "songs", err)
|
||||
return
|
||||
}
|
||||
if err := a.pool.QueryRow(r.Context(),
|
||||
`select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ func main() {
|
||||
if err := sweep(ctx, pool); err != nil {
|
||||
fatal("startup sweep", "error", err)
|
||||
}
|
||||
for _, dir := range []string{"audio", "tmp"} {
|
||||
for _, dir := range []string{"audio", "tmp", "avatars"} {
|
||||
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
||||
fatal("storage dir", "error", err, "dir", dir)
|
||||
}
|
||||
@@ -141,6 +141,15 @@ func (a *app) memberMux() *http.ServeMux {
|
||||
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
||||
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
||||
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
||||
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
||||
|
||||
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
|
||||
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
|
||||
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
|
||||
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
|
||||
|
||||
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
|
||||
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
|
||||
|
||||
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
|
||||
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
|
||||
@@ -164,6 +173,10 @@ func (a *app) adminMux() *http.ServeMux {
|
||||
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
||||
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
||||
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
||||
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
|
||||
mux.HandleFunc("GET /admin/reports", a.adminReports)
|
||||
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
|
||||
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
@@ -176,6 +176,17 @@ func downloadYouTube(ctx context.Context, url, outTemplate string) (string, erro
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is
|
||||
// the validation and the size cap in one — webp and avif included, which stdlib image cannot read.
|
||||
func toAvatarJPEG(ctx context.Context, in, out string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in,
|
||||
"-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256",
|
||||
"-frames:v", "1", "-q:v", "3", out).Run()
|
||||
}
|
||||
|
||||
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
|
||||
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
|
||||
// showing — "Invalid data found when processing input" beats "submission failed".
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const maxAvatarBytes = 5 << 20
|
||||
|
||||
type profileStats struct {
|
||||
SongsSubmitted int
|
||||
ReviewsWritten int
|
||||
AverageGiven *float64
|
||||
AverageReceived *float64
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string // only filled for your own profile
|
||||
Avatar *string
|
||||
CreatedAt time.Time
|
||||
Own bool
|
||||
Stats profileStats
|
||||
Songs []*songSummary
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func (p *profileView) Initials() string { m := member{Name: p.Name}; return m.Initials() }
|
||||
|
||||
func (a *app) avatarPath(userID int64) string {
|
||||
return filepath.Join(a.cfg.storageDir, "avatars", strconv.FormatInt(userID, 10)+".jpg")
|
||||
}
|
||||
|
||||
// Counts and history-wide averages only. A member's per-song opinions stay on the song pages —
|
||||
// per-song opinion is gated, whole-history aggregate is public.
|
||||
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
|
||||
var p profileView
|
||||
err := a.pool.QueryRow(ctx, `
|
||||
select u.id, u.name, u.email, u.avatar, u.created_at,
|
||||
(select count(*) from songs s where s.submitted_by = u.id),
|
||||
(select count(*) from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score)::float from reviews r
|
||||
join songs s on s.id = r.song_id where s.submitted_by = u.id)
|
||||
from users u where u.id = $1`, userID).
|
||||
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
|
||||
&p.Stats.SongsSubmitted, &p.Stats.ReviewsWritten,
|
||||
&p.Stats.AverageGiven, &p.Stats.AverageReceived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Own = viewerID == userID
|
||||
if !p.Own {
|
||||
p.Email = ""
|
||||
}
|
||||
|
||||
// Their songs, with the viewer's own reveal rule applied to each average.
|
||||
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
||||
from songs s join users u on u.id = s.submitted_by
|
||||
where s.submitted_by = $2
|
||||
order by s.created_at desc`, viewerID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Songs, err = scanSongs(rows)
|
||||
return &p, err
|
||||
}
|
||||
|
||||
func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
id := me.ID
|
||||
if raw := r.PathValue("id"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id = parsed
|
||||
}
|
||||
p, err := a.profile(r.Context(), me.ID, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("profile", "ctx", "auth", "error", err, "user", id)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "profile.html", page{Title: p.Name, Data: p})
|
||||
}
|
||||
|
||||
// Editing your own profile: name, email, password, avatar. Changing the password requires the
|
||||
// current one and drops your other sessions.
|
||||
func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
|
||||
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||
a.flash(w, "Kuva on liian suuri. Enintään 5 MB.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
name := clean(r.FormValue("name"), 50)
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
if name == "" || !strings.Contains(email, "@") {
|
||||
a.flash(w, "Tarkista nimi ja sähköpostiosoite.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
|
||||
a.flash(w, "Sähköpostiosoite on jo käytössä.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("edit profile", "ctx", "auth", "error", err, "user", me.ID)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword := r.FormValue("new_password"); newPassword != "" {
|
||||
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if file, _, err := r.FormFile("avatar"); err == nil {
|
||||
defer file.Close()
|
||||
if err := a.saveAvatar(r, me.ID, file); err != nil {
|
||||
slog.Error("avatar", "ctx", "auth", "error", err, "user", me.ID)
|
||||
a.flash(w, "Kuvaa ei voitu käsitellä. Onko se varmasti kuva?")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a.flash(w, "Tiedot tallennettu.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
|
||||
var hash string
|
||||
if err := a.pool.QueryRow(r.Context(),
|
||||
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(current)) != nil {
|
||||
a.flash(w, "Nykyinen salasana ei täsmää.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return false
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
// Every other session dies; this browser keeps its own.
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
|
||||
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
|
||||
}
|
||||
slog.Info("password changed", "ctx", "auth", "user", userID)
|
||||
return true
|
||||
}
|
||||
|
||||
// The re-encode through ffmpeg is the validation, the same trick as audio: it handles webp and
|
||||
// avif (stdlib image does not), and it caps what ends up on disk.
|
||||
func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
|
||||
tmp := filepath.Join(a.cfg.storageDir, "tmp", "avatar-"+strconv.FormatInt(userID, 10))
|
||||
dst, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(dst, io.LimitReader(file, maxAvatarBytes))
|
||||
dst.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
|
||||
out := a.avatarPath(userID)
|
||||
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.pool.Exec(r.Context(),
|
||||
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
|
||||
return err
|
||||
}
|
||||
|
||||
// Avatars are public: they are not secret, and gating them buys nothing.
|
||||
func (a *app) avatar(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(a.avatarPath(id))
|
||||
if err != nil {
|
||||
// No upload: the template renders initials instead, and a client sees avatar_url null.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
http.ServeContent(w, r, "avatar.jpg", info.ModTime(), f)
|
||||
}
|
||||
@@ -17,6 +17,16 @@ var assetFS embed.FS
|
||||
var funcs = template.FuncMap{
|
||||
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
||||
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
||||
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
||||
// Lets one board partial be called with a title and a list, instead of two near-identical
|
||||
// partials per leaderboard.
|
||||
"dict": func(pairs ...any) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
m[pairs[i].(string)] = pairs[i+1]
|
||||
}
|
||||
return m
|
||||
},
|
||||
}
|
||||
|
||||
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxReportBody = 2000
|
||||
|
||||
type report struct {
|
||||
ID int64
|
||||
Body string
|
||||
Page string
|
||||
UserAgent string
|
||||
Reporter string
|
||||
ResolvedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (r *report) Open() bool { return r.ResolvedAt == nil }
|
||||
|
||||
type reportPage struct {
|
||||
From string
|
||||
Mine []*report
|
||||
}
|
||||
|
||||
// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and
|
||||
// a page URL beat a taxonomy nobody fills in honestly.
|
||||
func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
|
||||
mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
slog.Error("list reports", "ctx", "reports", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
from := r.URL.Query().Get("from")
|
||||
if !strings.HasPrefix(from, "/") {
|
||||
from = "/" // never redirect off-site on the strength of a query parameter
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "report.html",
|
||||
page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}})
|
||||
}
|
||||
|
||||
// Seeing your own past reports is what stops the same bug arriving four times.
|
||||
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select id, body, coalesce(page, ''), resolved_at, created_at
|
||||
from reports where user_id = $1 order by created_at desc`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*report
|
||||
for rows.Next() {
|
||||
var rep report
|
||||
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &rep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
body := clean(r.FormValue("body"), maxReportBody)
|
||||
from := r.FormValue("from")
|
||||
if !strings.HasPrefix(from, "/") {
|
||||
from = "/"
|
||||
}
|
||||
if body == "" {
|
||||
a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.")
|
||||
http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// "Only on my phone" is the most common bug report and this answers it without asking.
|
||||
_, err := a.pool.Exec(r.Context(), `
|
||||
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
|
||||
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
|
||||
if err != nil {
|
||||
slog.Error("create report", "ctx", "reports", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("report filed", "ctx", "reports", "user", me.ID)
|
||||
a.flash(w, "Kiitos! Palaute on perillä.")
|
||||
http.Redirect(w, r, from, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.pool.Query(r.Context(), `
|
||||
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
|
||||
u.name, rep.resolved_at, rep.created_at
|
||||
from reports rep join users u on u.id = rep.user_id
|
||||
order by rep.resolved_at nulls first, rep.created_at desc`)
|
||||
if err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*report
|
||||
for rows.Next() {
|
||||
var rep report
|
||||
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter,
|
||||
&rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
out = append(out, &rep)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "admin_reports.html",
|
||||
page{Title: "Palautteet", Admin: true, Data: out})
|
||||
}
|
||||
|
||||
// A nullable timestamp rather than a status enum: smaller, and it tells you *when*.
|
||||
func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
|
||||
id); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one
|
||||
// route with a branch. The row and the file go together here too.
|
||||
func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id)
|
||||
if err != nil {
|
||||
adminError(w, "songs", err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
removeFile(a.audioPath(id))
|
||||
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
|
||||
a.flash(w, "Kappale poistettu.")
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type adminSong struct {
|
||||
ID int64
|
||||
Title string
|
||||
Artist string
|
||||
Submitter string
|
||||
Reviews int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select s.id, s.title, s.artist, u.name,
|
||||
(select count(*) from reviews r where r.song_id = s.id)::int, s.created_at
|
||||
from songs s join users u on u.id = s.submitted_by
|
||||
order by s.created_at desc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []adminSong
|
||||
for rows.Next() {
|
||||
var s adminSong
|
||||
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
|
||||
// rather than branching auth inside the member handler.
|
||||
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
|
||||
a.audio(w, r)
|
||||
}
|
||||
@@ -269,12 +269,19 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
os.Remove(a.audioPath(id))
|
||||
removeFile(a.audioPath(id))
|
||||
slog.Info("song deleted", "ctx", "songs", "song", id)
|
||||
a.flash(w, "Kappale poistettu.")
|
||||
http.Redirect(w, r, "/songs", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// A missing file is fine — the row is gone either way — but anything else is worth knowing about.
|
||||
func removeFile(path string) {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove file", "ctx", "songs", "error", err, "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
// --- audio ---
|
||||
|
||||
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
|
||||
|
||||
@@ -551,3 +551,42 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
|
||||
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
|
||||
.songcard:hover { transform: none; }
|
||||
}
|
||||
|
||||
/* --- stats --- */
|
||||
|
||||
.boards { display: grid; grid-template-columns: 1fr; gap: var(--space-5); }
|
||||
@media (min-width: 700px) { .boards { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (min-width: 1100px) { .boards { grid-template-columns: repeat(3, 1fr); } }
|
||||
|
||||
.board { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
|
||||
padding: var(--space-4); margin: 0; box-shadow: var(--shadow-card); }
|
||||
.board h2 { font-size: 1.1rem; margin-bottom: var(--space-3); }
|
||||
/* A counter rather than a list marker: `display: grid` on the <li> suppresses markers. */
|
||||
.board-list { list-style: none; counter-reset: rank; margin: 0; padding: 0; }
|
||||
.board-list li { display: grid; grid-template-columns: 1.6rem 1fr auto;
|
||||
column-gap: var(--space-2); align-items: baseline;
|
||||
padding: var(--space-2) 0; border-bottom: 1px solid var(--hairline); }
|
||||
.board-list li:last-child { border-bottom: 0; }
|
||||
.board-list li::before { counter-increment: rank; content: counter(rank) "."; color: var(--muted);
|
||||
font-family: var(--font-display); }
|
||||
.board-list li a { grid-column: 2; }
|
||||
.board-list li .value { grid-column: 3; grid-row: 1; font-family: var(--font-display);
|
||||
font-size: 1.1rem; color: var(--gold-1); }
|
||||
.board-list li .small { grid-column: 2; }
|
||||
.board-list li .small:last-child { grid-column: 3; text-align: right; }
|
||||
|
||||
/* --- profile --- */
|
||||
|
||||
.profilehead { display: flex; align-items: center; gap: var(--space-4); margin-bottom: var(--space-5); }
|
||||
.profilehead h1 { margin: 0; }
|
||||
.avatar.big { width: 4.5rem; height: 4.5rem; font-size: 1.6rem; object-fit: cover; }
|
||||
img.avatar { object-fit: cover; }
|
||||
|
||||
.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4);
|
||||
margin-bottom: var(--space-6); }
|
||||
@media (min-width: 700px) { .statgrid { grid-template-columns: repeat(4, 1fr); } }
|
||||
|
||||
.statcard { background: var(--surface); border: 1px solid var(--hairline); border-radius: var(--radius);
|
||||
padding: var(--space-5); text-align: center; display: flex; flex-direction: column;
|
||||
gap: var(--space-1); box-shadow: var(--shadow-card); }
|
||||
.statvalue { font-family: var(--font-display); font-size: 2rem; font-weight: 700; color: var(--gold-1); }
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
|
||||
// the club has weighed in, which is a real threshold rather than a formality.
|
||||
const minReviews = 3
|
||||
|
||||
type songStat struct {
|
||||
ID int64
|
||||
Title string
|
||||
Artist string
|
||||
Value float64
|
||||
ReviewCount int
|
||||
}
|
||||
|
||||
type userStat struct {
|
||||
ID int64
|
||||
Name string
|
||||
Value float64
|
||||
Count int
|
||||
Avatar *string
|
||||
}
|
||||
|
||||
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
|
||||
|
||||
type stats struct {
|
||||
MinReviews int
|
||||
|
||||
TopSongs []songStat
|
||||
BottomSongs []songStat
|
||||
MostDivisive []songStat
|
||||
MostUnified []songStat
|
||||
MostReviewed []songStat
|
||||
|
||||
Harshest []userStat
|
||||
MostGenerous []userStat
|
||||
MostActive []userStat
|
||||
MostProlific []userStat
|
||||
}
|
||||
|
||||
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
|
||||
// ties are common in a ten-person club, and without one Postgres may return a different ten each
|
||||
// time, so the page visibly reshuffles between reloads for no reason.
|
||||
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews
|
||||
from songs s join reviews r on r.song_id = s.id
|
||||
group by s.id
|
||||
having count(r.id) >= $1
|
||||
order by value `+direction+`, reviews desc, s.id asc
|
||||
limit 10`, minReviews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []songStat
|
||||
for rows.Next() {
|
||||
var s songStat
|
||||
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
|
||||
// member in the club forever.
|
||||
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
|
||||
from users u join reviews r on r.reviewer_id = u.id
|
||||
group by u.id
|
||||
having count(r.id) >= $1
|
||||
order by value `+direction+`, n desc, u.id asc
|
||||
limit 10`, minReviews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []userStat
|
||||
for rows.Next() {
|
||||
var u userStat
|
||||
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
|
||||
from users u join songs s on s.submitted_by = u.id
|
||||
group by u.id
|
||||
order by count(s.id) desc, u.id asc
|
||||
limit 10`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []userStat
|
||||
for rows.Next() {
|
||||
var u userStat
|
||||
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
|
||||
// /stats being a page you walk to deliberately.
|
||||
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := stats{MinReviews: minReviews}
|
||||
|
||||
var err error
|
||||
for _, load := range []func() error{
|
||||
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
||||
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
||||
func() (err error) {
|
||||
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
|
||||
return
|
||||
},
|
||||
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
|
||||
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
|
||||
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
||||
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
||||
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
|
||||
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
|
||||
} {
|
||||
if err = load(); err != nil {
|
||||
slog.Error("stats", "ctx", "songs", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
|
||||
// same ten come back in the same order every time.
|
||||
func TestLeaderboardThresholdAndOrder(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
submitter := a.seedMember(t, "[email protected]")
|
||||
var reviewers []int64
|
||||
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
|
||||
reviewers = append(reviewers, a.seedMember(t, e))
|
||||
}
|
||||
|
||||
loved := a.seedSong(t, submitter, "Rakastettu")
|
||||
hated := a.seedSong(t, submitter, "Vihattu")
|
||||
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
|
||||
|
||||
for _, r := range reviewers {
|
||||
a.seedReview(t, loved, r, 90)
|
||||
a.seedReview(t, hated, r, 20)
|
||||
}
|
||||
// One short of the threshold.
|
||||
a.seedReview(t, ignored, reviewers[0], 100)
|
||||
a.seedReview(t, ignored, reviewers[1], 100)
|
||||
|
||||
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(top) != 2 {
|
||||
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
|
||||
}
|
||||
if top[0].ID != loved || top[1].ID != hated {
|
||||
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
|
||||
}
|
||||
if top[0].Value != 90 || top[0].ReviewCount != 3 {
|
||||
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
|
||||
}
|
||||
|
||||
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bottom[0].ID != hated {
|
||||
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
|
||||
}
|
||||
|
||||
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
|
||||
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unified[0].Value != 0 {
|
||||
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
|
||||
}
|
||||
|
||||
// Ties are the common case in a ten-person club: the same query must return the same order.
|
||||
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
|
||||
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
|
||||
for i := range first {
|
||||
if first[i].ID != second[i].ID {
|
||||
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles are counts and history-wide averages. They never carry per-song opinions.
|
||||
func TestProfileStats(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
aino := a.seedMember(t, "[email protected]")
|
||||
bertta := a.seedMember(t, "[email protected]")
|
||||
|
||||
song := a.seedSong(t, aino, "Testikappale")
|
||||
a.seedReview(t, song, bertta, 80)
|
||||
other := a.seedSong(t, bertta, "Berttan kappale")
|
||||
a.seedReview(t, other, aino, 40)
|
||||
|
||||
p, err := a.profile(ctx, bertta, aino)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
|
||||
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
|
||||
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
|
||||
}
|
||||
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
|
||||
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
|
||||
}
|
||||
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
|
||||
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
|
||||
}
|
||||
// Viewing someone else's profile does not expose their email.
|
||||
if p.Email != "" {
|
||||
t.Fatalf("another member's email leaked: %q", p.Email)
|
||||
}
|
||||
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
|
||||
// its average here.
|
||||
if len(p.Songs) != 1 {
|
||||
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
|
||||
}
|
||||
if p.Songs[0].Average == nil {
|
||||
t.Fatal("a reviewer cannot see the average of a song they reviewed")
|
||||
}
|
||||
|
||||
// A third member who has reviewed nothing must not learn it from the profile page.
|
||||
cecilia := a.seedMember(t, "[email protected]")
|
||||
p, err = a.profile(ctx, cecilia, aino)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Songs[0].Average != nil {
|
||||
t.Fatal("profile leaked a song average to someone who has not reviewed it")
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user