From f51dcd743e4fb08f5c3917133fc5869145904727 Mon Sep 17 00:00:00 2001 From: Esa Kataja Date: Fri, 31 Jul 2026 23:17:40 +0300 Subject: [PATCH 1/2] Rebuild the review page as a channel strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app is about operating something — playing a track and setting a level on it — but every screen looked like a form. One metaphor now does three jobs. - Reviewing: a vertical fader beside the text, so the two things you do at once stop being a screen apart. Native range input, so keyboard, focus and form submission are unchanged; on mobile it lies down and the ticks reverse - The reveal: everyone's scores as a row of channels. The silhouette of that row is the spread, which the stats page can only tell you as a number - Profiles: given versus received as two faders, the one comparison that says something about a person The player is now a transport: play/pause, a range input for seeking so arrow keys come free, and a stereo level meter driven by a real AnalyserNode. It is progressive enhancement — the page ships native audio controls and the script takes over, so no JS means the browser's own player. The meter is dark until audio actually plays and stops when it does; reduced motion skips it entirely. Also: hidden scores are hatched rather than blank, the nav carries the queue count, "Seuraava jonossa" keeps the loop going after a review, leaderboards gained level bars and a range bar where divisive is the point, durations read 3:54, both lists can get back to the start, and the admin invite table lists unused codes instead of silently truncating at 50. Slogan restored from the original app, three decades on. --- admin.go | 18 +- render.go | 10 + songs.go | 36 +++- static/player.js | 143 +++++++++++++ static/style.css | 358 ++++++++++++++++++++++++++++++++- stats.go | 18 +- templates/admin.html | 14 +- templates/layout.html | 7 +- templates/login.html | 2 + templates/partials/player.html | 29 +-- templates/profile.html | 26 ++- templates/queue.html | 10 +- templates/song.html | 143 ++++++++----- templates/songs.html | 5 +- templates/stats.html | 13 +- templates/submission.html | 6 + 16 files changed, 735 insertions(+), 103 deletions(-) create mode 100644 static/player.js diff --git a/admin.go b/admin.go index 778806f..408be4f 100644 --- a/admin.go +++ b/admin.go @@ -29,17 +29,25 @@ type adminMember struct { } type dashboard struct { - Invites []adminInvite - Members []adminMember - Songs []adminSong - OpenCount int + Invites []adminInvite + SpentCount int + Members []adminMember + Songs []adminSong + OpenCount int } func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { var d dashboard + // Unused invites are the ones with a job to do; spent ones are counted, not listed. Truncating + // a list silently reads as "that's all of them". + if err := a.pool.QueryRow(r.Context(), + `select count(*)::int from invites where not is_valid`).Scan(&d.SpentCount); err != nil { + adminError(w, "invites", err) + return + } rows, err := a.pool.Query(r.Context(), - `select id, code, is_valid, created_at from invites order by created_at desc limit 50`) + `select id, code, is_valid, created_at from invites where is_valid order by created_at desc`) if err != nil { adminError(w, "invites", err) return diff --git a/render.go b/render.go index dfc7a40..73dc66c 100644 --- a/render.go +++ b/render.go @@ -57,6 +57,7 @@ type page struct { Flash string Path string Narrow bool // auth pages are a 420px column + Queued int // songs still owed a review, shown in the nav Data any } @@ -69,6 +70,15 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st } p.Member = memberFrom(r.Context()) p.Path = r.URL.Path + if p.Member != nil { + // The queue is a worklist, so its size belongs in the nav. + a.pool.QueryRow(r.Context(), ` + select count(*)::int from songs s + where s.submitted_by <> $1 + and not exists (select 1 from reviews r + where r.song_id = s.id and r.reviewer_id = $1)`, + p.Member.ID).Scan(&p.Queued) + } p.Flash = a.takeFlash(w, r) // Render to memory first: a template that fails halfway must not leave a half-written 200. diff --git a/songs.go b/songs.go index fde432c..3925c8e 100644 --- a/songs.go +++ b/songs.go @@ -36,11 +36,12 @@ func (s *songSummary) GenreLabel() string { return genreLabel(s.Genre) } func (s *songSummary) Revealed() bool { return s.Own || s.Reviewed } func (s *songSummary) Length() string { - return fmt.Sprintf("%d.%02d", s.Duration/60, s.Duration%60) + return fmt.Sprintf("%d:%02d", s.Duration/60, s.Duration%60) } type songList struct { Items []*songSummary + Cursor int64 // the cursor this page was fetched with; 0 means the first page NextCursor int64 // 0 when there is no next page Queue bool } @@ -87,7 +88,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err if err != nil { return nil, err } - return paginate(items, true), nil + return paginate(items, cursor, true), nil } // Everything, newest first. This is where a song lives once it has left the queue. @@ -104,12 +105,12 @@ func (a *app) browse(ctx context.Context, viewerID, cursor int64) (*songList, er if err != nil { return nil, err } - return paginate(items, false), nil + return paginate(items, cursor, false), nil } // One row over the page size is fetched so "is there more" needs no second count query. -func paginate(items []*songSummary, isQueue bool) *songList { - l := &songList{Items: items, Queue: isQueue} +func paginate(items []*songSummary, cursor int64, isQueue bool) *songList { + l := &songList{Items: items, Cursor: cursor, Queue: isQueue} if len(items) > pageSize { l.Items = items[:pageSize] l.NextCursor = l.Items[pageSize-1].ID @@ -151,7 +152,8 @@ type songDetail struct { Reviews []*review // nil when the reveal rule is withholding them ViewerReview *review CanReview bool - CanEdit bool // submitter, and the song is unlocked + CanEdit bool // submitter, and the song is unlocked + NextInQueue int64 // 0 when the queue is empty — keeps the loop moving after a review Genres []genre } @@ -186,9 +188,31 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er return nil, err } } + if !d.CanReview { + d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID) + if err != nil { + return nil, err + } + } return &d, nil } +// The oldest song the viewer still owes a review on. Offered right after they finish one, so +// draining the queue never means navigating back to it. +func (a *app) nextInQueue(ctx context.Context, viewerID, exceptID int64) (int64, error) { + var id int64 + err := a.pool.QueryRow(ctx, ` + select s.id from songs s + where s.submitted_by <> $1 and s.id <> $2 + and not exists (select 1 from reviews r where r.song_id = s.id and r.reviewer_id = $1) + order by s.created_at, s.id + limit 1`, viewerID, exceptID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + return id, err +} + func (a *app) songPage(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { diff --git a/static/player.js b/static/player.js new file mode 100644 index 0000000..fb96ab3 --- /dev/null +++ b/static/player.js @@ -0,0 +1,143 @@ +// Progressive enhancement: the page ships