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