Rebuild the review page as a channel strip
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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user