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:
Esa Kataja
2026-07-31 23:17:40 +03:00
parent 69eea8d707
commit f51dcd743e
16 changed files with 735 additions and 103 deletions
+9 -1
View File
@@ -30,6 +30,7 @@ type adminMember struct {
type dashboard struct {
Invites []adminInvite
SpentCount int
Members []adminMember
Songs []adminSong
OpenCount int
@@ -38,8 +39,15 @@ type dashboard struct {
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
+10
View File
@@ -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.
+29 -5
View File
@@ -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
@@ -152,6 +153,7 @@ type songDetail struct {
ViewerReview *review
CanReview bool
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 {
+143
View File
@@ -0,0 +1,143 @@
// Progressive enhancement: the page ships <audio controls>. If this script runs, it takes the
// controls off and drives the same element itself — playback, buffering, seeking and Range
// requests are untouched, because the element never changes.
(function () {
'use strict'
const fmt = (s) => {
if (!isFinite(s)) return ':––'
const m = Math.floor(s / 60)
return m + ':' + String(Math.floor(s % 60)).padStart(2, '0')
}
const SEGMENTS = 18
const quiet = window.matchMedia('(prefers-reduced-motion: reduce)').matches
function enhance(wrap) {
const audio = wrap.querySelector('audio')
if (!audio) return
audio.removeAttribute('controls')
const total = Number(wrap.dataset.duration) || 0
wrap.insertAdjacentHTML('beforeend', `
<div class="transport">
<button type="button" class="tp-play" aria-label="Toista">
<span class="tp-icon" aria-hidden="true"></span>
</button>
<div class="tp-mid">
<input type="range" class="tp-seek" min="0" max="${total || 100}" step="0.1" value="0"
aria-label="Kelaus">
<div class="meter" aria-hidden="true">
<div class="meter-row"><span class="lbl">L</span><span class="segs"></span></div>
<div class="meter-row"><span class="lbl">R</span><span class="segs"></span></div>
</div>
</div>
<span class="tp-time"><b>0:00</b> / ${fmt(total)}</span>
</div>`)
const play = wrap.querySelector('.tp-play')
const seek = wrap.querySelector('.tp-seek')
const time = wrap.querySelector('.tp-time b')
const rows = wrap.querySelectorAll('.meter .segs')
for (const row of rows) {
row.innerHTML = '<span class="seg"></span>'.repeat(SEGMENTS)
}
const segs = [...rows].map((r) => [...r.children])
// --- transport ---
play.addEventListener('click', () => (audio.paused ? audio.play() : audio.pause()))
const setPlaying = (playing) => {
wrap.classList.toggle('playing', playing)
play.setAttribute('aria-label', playing ? 'Tauko' : 'Toista')
}
audio.addEventListener('play', () => { setPlaying(true); startMeter() })
audio.addEventListener('pause', () => setPlaying(false))
audio.addEventListener('ended', () => setPlaying(false))
audio.addEventListener('loadedmetadata', () => {
if (isFinite(audio.duration)) {
seek.max = audio.duration
wrap.querySelector('.tp-time').lastChild.textContent = ' / ' + fmt(audio.duration)
}
})
let scrubbing = false
seek.addEventListener('input', () => {
scrubbing = true
time.textContent = fmt(Number(seek.value))
})
seek.addEventListener('change', () => {
audio.currentTime = Number(seek.value)
scrubbing = false
})
audio.addEventListener('timeupdate', () => {
if (scrubbing) return
seek.value = audio.currentTime
time.textContent = fmt(audio.currentTime)
seek.style.setProperty('--pct', (audio.currentTime / (Number(seek.max) || 1)) * 100 + '%')
})
// --- meter ---
//
// A real analyser, not a decorative loop: it is dark until the audio actually plays, and it
// stops the moment playback does. The AudioContext can only start from a gesture, so it is
// created on first play. MediaElementSource reroutes the audio, so the graph must reach the
// destination or the sound stops.
let ctx, analysers, raf
function startMeter() {
if (quiet || raf) return
if (!ctx) {
try {
ctx = new (window.AudioContext || window.webkitAudioContext)()
const src = ctx.createMediaElementSource(audio)
const split = ctx.createChannelSplitter(2)
analysers = [ctx.createAnalyser(), ctx.createAnalyser()]
analysers.forEach((a, i) => {
a.fftSize = 256
split.connect(a, i)
})
src.connect(split)
src.connect(ctx.destination)
} catch (e) {
return // no Web Audio: the transport still works, the meter simply never lights
}
}
ctx.resume()
const buf = new Uint8Array(analysers[0].fftSize)
const held = [0, 0]
const draw = () => {
if (audio.paused) {
segs.forEach((row) => row.forEach((s) => (s.className = 'seg')))
raf = null
return
}
analysers.forEach((a, ch) => {
a.getByteTimeDomainData(buf)
let peak = 0
for (let i = 0; i < buf.length; i++) {
const v = Math.abs(buf[i] - 128) / 128
if (v > peak) peak = v
}
// Fall slower than it rises, the way a real meter behaves.
held[ch] = peak > held[ch] ? peak : held[ch] * 0.88
const lit = Math.round(held[ch] * SEGMENTS)
segs[ch].forEach((s, i) => {
s.className = 'seg' +
(i < lit ? ' on' + (i >= SEGMENTS - 3 ? ' peak' : i >= SEGMENTS - 7 ? ' hot' : '') : '')
})
})
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.playerwrap').forEach(enhance)
})
})()
+351 -7
View File
@@ -21,6 +21,7 @@
--hairline: #2a2a2a;
--divider: #333333;
--input-border: #444444;
--track: #2a2a2a;
/* Text */
--text: #e0e0e0;
@@ -366,14 +367,171 @@ button.link { background: none; border: 0; color: var(--primary); padding: 0;
font-weight: 400; font-size: 0.9rem; text-decoration: underline; }
button.link:hover { background: none; color: var(--primary-hover); }
.scorerow { display: flex; align-items: center; gap: var(--space-4); }
.scorerow input[type="range"] { flex: 1; accent-color: var(--primary); }
.scorerow output { font-family: var(--font-display); font-size: 2rem; font-weight: 700;
color: var(--gold-1); min-width: 3ch; text-align: right; }
/* --- the channel strip --- */
/* Pulled up so it hugs the control. */
.scorescale { display: flex; justify-content: space-between; font-size: 0.9rem;
color: var(--muted); opacity: 0.6; margin-top: calc(-1 * var(--space-2)); }
/* Score and text side by side: the two things you do at once stop being a screen apart. */
.strip {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-5);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-5);
box-shadow: var(--shadow-card);
margin-bottom: var(--space-5);
}
.deck { display: flex; flex-direction: column; gap: var(--space-3); min-width: 0; }
.deck .grow { flex: 1; }
.deck .player { margin: 0; }
.deckfoot { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
.fader { display: grid; grid-template-columns: auto auto; grid-template-rows: 1fr auto;
gap: var(--space-2); align-items: stretch; }
.ticks { display: flex; flex-direction: column; justify-content: space-between; text-align: right;
font-size: 0.7rem; color: var(--muted); font-family: var(--font-display); }
/* Vertical is native: writing-mode plus rtl gives bottom-to-top travel with no custom widget. */
.fader input[type="range"] {
writing-mode: vertical-lr;
direction: rtl;
-webkit-appearance: none;
appearance: none;
width: 2rem;
height: 100%;
min-height: 15rem;
padding: 0;
border: 0;
background: transparent;
cursor: ns-resize;
}
.fader input[type="range"]::-webkit-slider-runnable-track {
width: 8px;
background: var(--track);
border: 1px solid var(--input-border);
border-radius: var(--radius-pill);
}
.fader input[type="range"]::-moz-range-track {
width: 8px;
background: var(--track);
border: 1px solid var(--input-border);
border-radius: var(--radius-pill);
}
/* A cap, not a knob: this is a fader. */
.fader input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 2rem;
height: 14px;
margin-left: -12px;
border-radius: 2px;
background: linear-gradient(var(--gold-1), var(--gold-2));
border: 1px solid var(--bar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
}
.fader input[type="range"]::-moz-range-thumb {
width: 2rem;
height: 14px;
border-radius: 2px;
background: linear-gradient(var(--gold-1), var(--gold-2));
border: 1px solid var(--bar);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
}
.readout {
grid-column: 1 / -1;
font-family: var(--font-display);
font-size: 2rem;
font-weight: 700;
color: var(--gold-1);
text-align: center;
background: var(--bar);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: 0 var(--space-2);
}
/* --- the reveal: one channel per reviewer --- */
.channels { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: flex-end;
margin-bottom: var(--space-5); }
.chan { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); width: 2.6rem; }
.chan-track {
position: relative;
width: 8px;
height: 8rem;
background: var(--track);
border: 1px solid var(--input-border);
border-radius: var(--radius-pill);
animation: chan-rise var(--duration-fast) var(--ease-out) backwards;
animation-delay: calc(var(--i, 0) * 40ms);
}
/* The cap sits at the score: the row's silhouette is the spread. */
.chan-cap {
position: absolute;
left: 50%;
bottom: calc(var(--v) * 1%);
transform: translate(-50%, 50%);
width: 1.8rem;
height: 10px;
border-radius: 2px;
background: var(--primary);
border: 1px solid var(--bar);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
/* The filled part below the cap reads as level. */
.chan-cap::after {
content: "";
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
width: 4px;
height: 8rem;
background: linear-gradient(var(--primary), transparent);
opacity: 0.5;
}
.chan.own .chan-cap { background: var(--gold-1); }
.chan.own .chan-cap::after { background: linear-gradient(var(--gold-1), transparent); }
.chan.own .chan-score { color: var(--gold-1); }
.chan.avg .chan-track { background: transparent; border-left: 1px dashed var(--input-border); width: 1px; }
.chan.avg .chan-cap { background: var(--gold-2); width: 2rem; height: 2px; border: 0; }
.chan.avg .chan-cap::after { display: none; }
.chan-score { font-family: var(--font-display); font-size: 0.95rem; color: var(--text); }
.chan-who { font-size: 0.7rem; color: var(--muted); }
@keyframes chan-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* Hidden scores are hatched rather than absent: "locked", not "no data". */
.sealed {
display: inline-block;
width: 2.2rem;
height: 1rem;
vertical-align: -2px;
border-radius: 2px;
background: repeating-linear-gradient(-45deg, rgba(224, 224, 224, 0.18) 0 6px, transparent 6px 12px),
var(--bar);
border: 1px solid var(--input-border);
}
.sealed-note { color: var(--muted); display: flex; align-items: center; gap: var(--space-2); }
.reviewtext { white-space: pre-wrap; line-height: 1.6; }
.nextup { font-family: var(--font-display); text-transform: uppercase; font-size: 1.1rem; }
/* --- drop zone --- */
@@ -539,6 +697,16 @@ code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
main { padding: var(--space-5) var(--space-4); }
.toasts { left: var(--space-4); max-width: none; }
/* A 200px fader on a phone is worse than a horizontal one. */
.strip { grid-template-columns: 1fr; gap: var(--space-3); padding: var(--space-4); }
.fader { grid-template-columns: 1fr auto; grid-template-rows: auto; align-items: center; }
.fader input[type="range"] { writing-mode: horizontal-tb; direction: ltr;
width: 100%; height: auto; min-height: 0; }
/* row-reverse: the markup is top-to-bottom (100 first), a horizontal fader runs 1 → 100. */
.ticks { flex-direction: row-reverse; justify-content: space-between; order: 2;
grid-column: 1 / -1; }
.readout { grid-column: auto; font-size: 1.5rem; }
}
@keyframes panel-down {
@@ -590,3 +758,179 @@ img.avatar { object-fit: cover; }
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); }
/* --- leaderboard bars --- */
.bar { grid-column: 2 / -1; position: relative; height: 4px; margin-top: var(--space-1);
background: var(--track); border-radius: var(--radius-pill); overflow: hidden; }
.bar .fill { position: absolute; top: 0; bottom: 0; left: 0; background: var(--primary);
border-radius: var(--radius-pill); }
.bar.range .fill { background: linear-gradient(90deg, var(--primary), var(--gold-1)); }
/* --- nav queue count --- */
.count { display: inline-block; min-width: 1.4em; padding: 0 0.35em; border-radius: var(--radius-pill);
background: var(--primary); color: var(--primary-inverse); font-size: 0.75rem;
font-weight: 700; text-align: center; }
/* --- profile comparison --- */
.statcard.wide { grid-column: span 2; }
.channels.compare { justify-content: center; gap: var(--space-5); margin: 0; }
.channels.compare .chan-track { height: 5rem; }
.channels.compare .chan-cap::after { height: 5rem; }
.channels.compare .chan { width: 4rem; }
/* --- submission progress --- */
.segments { display: flex; gap: var(--space-1); list-style: none; margin: 0 0 var(--space-3);
padding: 0; font-family: var(--font-display); text-transform: uppercase;
font-size: 0.75rem; letter-spacing: 0.04em; }
.segments li { flex: 1; padding: var(--space-1) var(--space-2); text-align: center;
color: var(--muted); background: var(--bar); border: 1px solid var(--hairline);
border-radius: var(--radius); }
.segments li.now { color: var(--primary-inverse); background: var(--primary); border-color: var(--primary); }
.segments li.done { color: var(--gold-1); border-color: var(--input-border); }
.segments li.failed { color: var(--pending); border-color: var(--pending); background: var(--pending-bg); }
/* The score chip on a card, sealed: the score exists, you just haven't earned it yet.
The specificity has to beat .songcard .scorebadge, which sets its own background. */
.songcard .scorebadge.sealed {
display: block;
width: 2.4rem;
height: 1.4rem;
padding: 0;
background: repeating-linear-gradient(-45deg, rgba(224, 224, 224, 0.22) 0 5px, transparent 5px 10px),
var(--bar);
border: 1px solid var(--input-border);
}
/* --- transport --- */
.playerwrap { margin: 0 0 var(--space-3); }
.playerwrap .player { width: 100%; }
/* Once enhanced the native element is only a source of sound. */
.playerwrap:has(.transport) .player { display: none; }
.transport {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: var(--space-4);
background: var(--bar);
border: 1px solid var(--divider);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
}
.tp-play {
width: 3rem;
height: 3rem;
flex: none;
padding: 0;
border-radius: 50%;
background: var(--primary);
border: 0;
display: grid;
place-items: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
.tp-play:hover { background: var(--primary-hover); }
/* Drawn shapes, not glyphs: ▶ and ❚❚ render differently on every platform. */
.tp-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 9px 0 9px 14px;
border-color: transparent transparent transparent var(--primary-inverse);
margin-left: 3px;
}
.playing .tp-icon {
width: 14px;
height: 16px;
margin-left: 0;
border: 0;
background: linear-gradient(90deg, var(--primary-inverse) 0 5px, transparent 5px 9px,
var(--primary-inverse) 9px 14px);
}
.tp-mid { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
/* A range input for seeking, so arrow keys and the focus ring come free. */
.tp-seek {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 14px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.tp-seek::-webkit-slider-runnable-track {
height: 6px;
border-radius: var(--radius-pill);
background: linear-gradient(90deg, var(--primary) 0 var(--pct, 0%), var(--track) var(--pct, 0%));
border: 1px solid var(--input-border);
}
.tp-seek::-moz-range-track {
height: 6px;
border-radius: var(--radius-pill);
background: var(--track);
border: 1px solid var(--input-border);
}
.tp-seek::-moz-range-progress { height: 6px; border-radius: var(--radius-pill); background: var(--primary); }
.tp-seek::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
margin-top: -4px;
border-radius: 50%;
background: var(--gold-1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
.tp-seek::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: var(--gold-1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}
.tp-time { font-family: var(--font-display); font-size: 0.95rem; color: var(--muted); white-space: nowrap; }
.tp-time b { color: var(--text); font-weight: 700; }
/* --- level meter --- */
.meter { display: flex; flex-direction: column; gap: 3px; }
.meter-row { display: flex; gap: var(--space-2); align-items: center; }
.meter-row .lbl { font-family: var(--font-display); font-size: 0.65rem; color: var(--muted); width: 0.8rem; }
.meter .segs { display: flex; gap: 2px; }
.seg { width: 6px; height: 11px; border-radius: 1px; background: var(--track);
border: 1px solid rgba(0, 0, 0, 0.4); }
.seg.on { background: var(--gold-2); }
.seg.on.hot { background: var(--primary); }
.seg.on.peak { background: #c0392b; }
@media (max-width: 768px) {
.transport { gap: var(--space-3); padding: var(--space-3); }
.tp-play { width: 2.6rem; height: 2.6rem; }
.seg { width: 4px; }
}
.pager { display: flex; gap: var(--space-5); margin-top: var(--space-5); }
/* The slogan is a mark, so it is set in the display face rather than the body face. */
.slogan { font-family: var(--font-display); letter-spacing: 0.02em; }
.slogan.hero { color: var(--gold-2); font-size: 1.1rem; text-transform: uppercase;
margin: calc(-1 * var(--space-2)) 0 var(--space-5); }
+16 -2
View File
@@ -16,6 +16,18 @@ type songStat struct {
Artist string
Value float64
ReviewCount int
Min int // the spread, which is what "divisive" actually means
Max int
}
// Percentages for the bars, so the templates hold no arithmetic.
func (s songStat) Pct() float64 { return s.Value }
func (s songStat) MinPct() float64 { return float64(s.Min) }
func (s songStat) SpanPct() float64 {
if s.Max <= s.Min {
return 1
}
return float64(s.Max - s.Min)
}
type userStat struct {
@@ -48,7 +60,8 @@ type stats struct {
// 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
select s.id, s.title, s.artist, `+valueExpr+`::float as value, count(r.id)::int as reviews,
min(r.score)::int, max(r.score)::int
from songs s join reviews r on r.song_id = s.id
group by s.id
having count(r.id) >= $1
@@ -61,7 +74,8 @@ func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string)
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 {
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Value, &s.ReviewCount,
&s.Min, &s.Max); err != nil {
return nil, err
}
out = append(out, s)
+5 -7
View File
@@ -14,21 +14,19 @@
{{range .Data.Invites}}
<tr>
<td>
{{if .IsValid}}
<a href="{{.Link}}">{{.Link}}</a>
{{else}}
<code class="muted">{{.Code}}</code>
{{end}}
</td>
<td class="nowrap"><span class="dot {{if .IsValid}}on{{else}}off{{end}}"></span> {{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td>
<td class="nowrap"><span class="dot on"></span> käyttämätön</td>
<td>{{fidate .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">Ei kutsuja.</td></tr>
<tr><td colspan="3" class="muted">Ei käyttämättömiä kutsuja.</td></tr>
{{end}}
</tbody>
</table>
<p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.</p>
<p class="muted small">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi
täytettynä. Lista näyttää käyttämättömät kutsut{{if .Data.SpentCount}}; käytettyjä on
{{.Data.SpentCount}}{{end}}.</p>
</div>
</section>
+4 -3
View File
@@ -8,6 +8,7 @@
<link rel="preload" href="/static/fonts/oswald.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/player.js" defer></script>
</head>
<body>
<header class="topbar">
@@ -19,7 +20,7 @@
<span></span>
{{else if .Member}}
<nav class="navlinks">
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono</a>
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</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>
@@ -39,7 +40,7 @@
<details class="mobilenav">
<summary aria-label="Valikko"></summary>
<div class="panel">
<a href="/">Jono</a>
<a href="/">Jono{{if .Queued}} <span class="count">{{.Queued}}</span>{{end}}</a>
<a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a>
@@ -62,7 +63,7 @@
<!-- 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.
<span class="slogan">We know good music, baby!</span>
</footer>
{{with .Flash}}
+2
View File
@@ -1,5 +1,7 @@
{{define "content"}}
<h1>Kirjaudu</h1>
<!-- The app's own slogan, three decades old. A mark, not interface copy, so it stays English. -->
<p class="slogan hero">We know good music, baby!</p>
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
+16 -11
View File
@@ -1,10 +1,15 @@
{{define "player"}}
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
the browser's own player, which is plain but complete. -->
<div class="playerwrap" data-duration="{{.Duration}}">
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
</div>
{{end}}
{{define "songcard"}}
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
{{with .Average}}<span class="scorebadge">{{score .}}</span>{{end}}
{{if .Average}}<span class="scorebadge">{{score .Average}}</span>
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Pisteet paljastuvat kun arvostelet"></span>{{end}}
<span class="title">{{.Title}}</span>
<span class="artist">{{.Artist}}</span>
<span class="meta">
@@ -19,15 +24,15 @@
</a>
{{end}}
{{define "scorefield"}}
<label>Pisteet
<span class="scorerow">
{{define "fader"}}
<!-- A native range input turned vertical: keyboard, form submission and the value all stay free. -->
<div class="fader">
<span class="ticks" aria-hidden="true">
<span>100</span><span>75</span><span>50</span><span>25</span><span>1</span>
</span>
<input type="range" name="score" min="1" max="100" value="{{.}}"
oninput="this.nextElementSibling.value = this.value">
<output>{{.}}</output>
</span>
</label>
<span class="scorescale" aria-hidden="true">
<span>1</span><span>25</span><span>50</span><span>75</span><span>100</span>
</span>
aria-label="Pisteet 1100"
oninput="this.closest('.fader').querySelector('output').value = this.value">
<output class="readout">{{.}}</output>
</div>
{{end}}
+24 -2
View File
@@ -15,8 +15,30 @@
<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>
<!-- The one comparison that says something about a person, in the same language as the review
form: what they give versus what they get. -->
<div class="statcard wide">
<div class="channels compare">
{{if $p.Stats.AverageGiven}}
<div class="chan" style="--v: {{score $p.Stats.AverageGiven}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $p.Stats.AverageGiven}}</span>
<span class="chan-who">antanut</span>
</div>
{{end}}
{{if $p.Stats.AverageReceived}}
<div class="chan own" style="--v: {{score $p.Stats.AverageReceived}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $p.Stats.AverageReceived}}</span>
<span class="chan-who">saanut</span>
</div>
{{end}}
{{if and (not $p.Stats.AverageGiven) (not $p.Stats.AverageReceived)}}
<p class="muted small">Ei vielä pisteitä kumpaankaan suuntaan.</p>
{{end}}
</div>
</div>
</div>
{{if $p.Own}}
+7 -3
View File
@@ -7,9 +7,13 @@
<div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}}
</div>
{{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}}
<p class="pager">
{{if .Data.Cursor}}<a href="/">← Alkuun</a>{{end}}
{{with .Data.NextCursor}}<a href="/?cursor={{.}}">Lisää →</a>{{end}}
</p>
{{else}}
<p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p>
<p><a href="/submit">Lähetä kappale</a> tai selaa <a href="/songs">kaikkia kappaleita</a>.</p>
<p class="empty">Kaikki kuunneltu.</p>
<p>Olet arvostellut kaiken, mitä muut ovat lähettäneet.
<a href="/submit">Lähetä kappale</a> tai lue <a href="/songs">mitä muut sanoivat</a>.</p>
{{end}}
{{end}}
+65 -26
View File
@@ -5,15 +5,10 @@
<strong>{{$s.Artist}}</strong>
<span class="badge genre">{{$s.GenreLabel}}</span>
<span>{{$s.Length}}</span>
<span>lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}}</span>
<span>lähetti {{$s.Submitter}} {{fidate $s.CreatedAt}}</span>
{{if $s.Own}}<span class="badge admin">oma kappale</span>{{end}}
</p>
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
{{with $s.SourceURL}}<p class="muted"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if $s.CanEdit}}
<details class="editbox">
<summary>Muokkaa tietoja</summary>
@@ -33,7 +28,7 @@
</form>
<form method="post" action="/songs/{{$s.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
<button type="submit" class="danger ghost">Poista kappale</button>
<button type="submit" class="ghost danger">Poista kappale</button>
</form>
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
</details>
@@ -41,51 +36,87 @@
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
{{end}}
<section>
{{if $s.CanReview}}
<h2>Arvostele</h2>
<form method="post" action="/songs/{{$s.ID}}/review" class="stack">
{{template "scorefield" 50}}
<label>Arvostelu
<textarea name="text" rows="6" maxlength="5000" required
<!-- The channel strip: the fader is the score, the panel beside it is everything else you do
while the track plays. -->
<form method="post" action="/songs/{{$s.ID}}/review" class="strip">
{{template "fader" 50}}
<div class="deck">
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
<label class="grow">Arvostelu
<textarea name="text" rows="8" maxlength="5000" required
placeholder="Mitä kuulit?"></textarea>
</label>
<div class="deckfoot">
<button type="submit">Tallenna arvostelu</button>
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
tai poistaa arvostelusi 30 minuutin ajan.</span>
</div>
</div>
</form>
<p class="muted small">Muiden arvostelut ja pisteet paljastuvat kun olet tallentanut omasi.
Voit muokata tai poistaa arvostelusi 30 minuutin ajan.</p>
{{else if $s.ViewerReview}}
{{else}}
{{template "player" $s}}
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
{{end}}
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
{{if $s.ViewerReview}}
<section>
<h2>Oma arvostelusi</h2>
{{with $s.ViewerReview}}
{{if .CanEdit}}
<form method="post" action="/reviews/{{.ID}}" class="stack">
<form method="post" action="/reviews/{{.ID}}" class="strip">
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
{{template "scorefield" .Score}}
<label>Arvostelu
{{template "fader" .Score}}
<div class="deck">
<label class="grow">Arvostelu
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
</label>
<div class="deckfoot">
<button type="submit">Päivitä</button>
<span class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</span>
</div>
</div>
</form>
<form method="post" action="/reviews/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko arvostelu?')">
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
<button type="submit" class="danger">Poista arvostelu</button>
<button type="submit" class="ghost danger">Poista arvostelu</button>
</form>
<p class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</p>
{{else}}
<p class="score">{{.Score}}</p>
<p>{{.Text}}</p>
<p class="reviewtext">{{.Text}}</p>
<p class="muted small">Muokkausaika on päättynyt.</p>
{{end}}
{{end}}
{{end}}
</section>
{{end}}
<section>
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
{{if $s.Revealed}}
{{if $s.Average}}<p class="average">Keskiarvo <strong>{{score $s.Average}}</strong></p>{{end}}
{{if $s.Reviews}}
<!-- One channel per reviewer: the spread across the row is what "divisive" looks like. -->
<div class="channels">
{{range $i, $r := $s.Reviews}}
<div class="chan{{if $r.Own}} own{{end}}" style="--v: {{$r.Score}}; --i: {{$i}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{$r.Score}}</span>
<span class="chan-who" title="{{$r.Reviewer}}">{{$r.Initials}}</span>
</div>
{{end}}
{{if $s.Average}}
<div class="chan avg" style="--v: {{score $s.Average}}">
<span class="chan-track"><span class="chan-cap"></span></span>
<span class="chan-score">{{score $s.Average}}</span>
<span class="chan-who">ka.</span>
</div>
{{end}}
</div>
{{range $s.Reviews}}
<article class="review{{if .Own}} own{{end}}">
<header>
@@ -96,12 +127,20 @@
</header>
<p>{{.Text}}</p>
</article>
{{end}}
{{else}}
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
{{end}}
{{else}}
<p class="muted">Muiden arvostelut ja keskiarvo näkyvät kun olet kirjoittanut omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}</p>
<p class="sealed-note">
<span class="sealed" aria-hidden="true"></span>
Muiden pisteet ja arvostelut paljastuvat kun kirjoitat omasi.
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}
</p>
{{end}}
</section>
{{if $s.NextInQueue}}
<p class="nextup"><a href="/songs/{{$s.NextInQueue}}">Seuraava jonossa →</a></p>
{{end}}
{{end}}
+4 -1
View File
@@ -5,7 +5,10 @@
<div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}}
</div>
{{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}}
<p class="pager">
{{if .Data.Cursor}}<a href="/songs">← Uusimmat</a>{{end}}
{{with .Data.NextCursor}}<a href="/songs?cursor={{.}}">Vanhempia →</a>{{end}}
</p>
{{else}}
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
<p><a href="/submit">Lähetä ensimmäinen</a>.</p>
+11 -2
View File
@@ -9,6 +9,14 @@
<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}}
{{if $.Spread}}
<!-- Where the scores actually landed: a range says more than a deviation. -->
<span class="bar range" title="{{.Min}}{{.Max}}">
<span class="fill" style="left: {{.MinPct}}%; width: {{.SpanPct}}%"></span>
</span>
{{else if not $.Count}}
<span class="bar"><span class="fill" style="width: {{.Pct}}%"></span></span>
{{end}}
</li>
{{end}}
</ol>
@@ -28,6 +36,7 @@
<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}}
{{if not $.Count}}<span class="bar"><span class="fill" style="width: {{value .Value}}%"></span></span>{{end}}
</li>
{{end}}
</ol>
@@ -45,8 +54,8 @@
<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" "Riitaisimmat" "Items" .Data.MostDivisive "Spread" true}}
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified "Spread" true}}
{{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}}
+6
View File
@@ -1,5 +1,11 @@
{{define "submission-status"}}
<div class="status{{if .Failed}} failed{{end}}" {{if not .Done}}hx-get="/submit/{{.ID}}/status" hx-trigger="every 2s" hx-swap="outerHTML"{{end}}>
<ol class="segments" aria-label="Lähetyksen tila">
<li class="{{if .Done}}done{{else}}now{{end}}">Lähetetty</li>
<li class="{{if .Done}}done{{else if eq .Status "converting"}}now{{else if eq .Status "downloading"}}now{{end}}">
{{if eq .Status "downloading"}}Ladataan{{else}}Muunnetaan{{end}}</li>
<li class="{{if .Ready}}done{{else if .Failed}}failed{{end}}">{{if .Failed}}Epäonnistui{{else}}Valmis{{end}}</li>
</ol>
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}