Merge the channel-strip UI work

Reviewing, the reveal, profiles and the player now share one metaphor. See
f51dcd7 for the reasoning.
This commit is contained in:
Esa Kataja
2026-07-31 23:21:11 +03:00
16 changed files with 739 additions and 103 deletions
+9 -1
View File
@@ -30,6 +30,7 @@ type adminMember struct {
type dashboard struct { type dashboard struct {
Invites []adminInvite Invites []adminInvite
SpentCount int
Members []adminMember Members []adminMember
Songs []adminSong Songs []adminSong
OpenCount int OpenCount int
@@ -38,8 +39,15 @@ type dashboard struct {
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) { func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
var d dashboard 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(), 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 { if err != nil {
adminError(w, "invites", err) adminError(w, "invites", err)
return return
+10
View File
@@ -57,6 +57,7 @@ type page struct {
Flash string Flash string
Path string Path string
Narrow bool // auth pages are a 420px column Narrow bool // auth pages are a 420px column
Queued int // songs still owed a review, shown in the nav
Data any 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.Member = memberFrom(r.Context())
p.Path = r.URL.Path 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) p.Flash = a.takeFlash(w, r)
// Render to memory first: a template that fails halfway must not leave a half-written 200. // 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) Revealed() bool { return s.Own || s.Reviewed }
func (s *songSummary) Length() string { 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 { type songList struct {
Items []*songSummary 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 NextCursor int64 // 0 when there is no next page
Queue bool Queue bool
} }
@@ -87,7 +88,7 @@ func (a *app) queue(ctx context.Context, viewerID, cursor int64) (*songList, err
if err != nil { if err != nil {
return nil, err 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. // 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 { if err != nil {
return nil, err 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. // One row over the page size is fetched so "is there more" needs no second count query.
func paginate(items []*songSummary, isQueue bool) *songList { func paginate(items []*songSummary, cursor int64, isQueue bool) *songList {
l := &songList{Items: items, Queue: isQueue} l := &songList{Items: items, Cursor: cursor, Queue: isQueue}
if len(items) > pageSize { if len(items) > pageSize {
l.Items = items[:pageSize] l.Items = items[:pageSize]
l.NextCursor = l.Items[pageSize-1].ID l.NextCursor = l.Items[pageSize-1].ID
@@ -152,6 +153,7 @@ type songDetail struct {
ViewerReview *review ViewerReview *review
CanReview bool 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 Genres []genre
} }
@@ -186,9 +188,31 @@ func (a *app) song(ctx context.Context, viewerID, songID int64) (*songDetail, er
return nil, err return nil, err
} }
} }
if !d.CanReview {
d.NextInQueue, err = a.nextInQueue(ctx, viewerID, songID)
if err != nil {
return nil, err
}
}
return &d, nil 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) { func (a *app) songPage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil { 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)
})
})()
+354 -7
View File
@@ -21,6 +21,7 @@
--hairline: #2a2a2a; --hairline: #2a2a2a;
--divider: #333333; --divider: #333333;
--input-border: #444444; --input-border: #444444;
--track: #2a2a2a;
/* Text */ /* Text */
--text: #e0e0e0; --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; } font-weight: 400; font-size: 0.9rem; text-decoration: underline; }
button.link:hover { background: none; color: var(--primary-hover); } button.link:hover { background: none; color: var(--primary-hover); }
.scorerow { display: flex; align-items: center; gap: var(--space-4); } /* --- the channel strip --- */
.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; }
/* Pulled up so it hugs the control. */ /* Score and text side by side: the two things you do at once stop being a screen apart. */
.scorescale { display: flex; justify-content: space-between; font-size: 0.9rem; .strip {
color: var(--muted); opacity: 0.6; margin-top: calc(-1 * var(--space-2)); } 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 --- */ /* --- 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); } main { padding: var(--space-5) var(--space-4); }
.toasts { left: var(--space-4); max-width: none; } .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 { @keyframes panel-down {
@@ -590,3 +758,182 @@ img.avatar { object-fit: cover; }
padding: var(--space-5); text-align: center; display: flex; flex-direction: column; padding: var(--space-5); text-align: center; display: flex; flex-direction: column;
gap: var(--space-1); box-shadow: var(--shadow-card); } 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); } .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); }
.copyright { color: var(--muted); }
.copyright::before { content: "·"; margin: 0 var(--space-2); }
+16 -2
View File
@@ -16,6 +16,18 @@ type songStat struct {
Artist string Artist string
Value float64 Value float64
ReviewCount int 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 { type userStat struct {
@@ -48,7 +60,8 @@ type stats struct {
// time, so the page visibly reshuffles between reloads for no reason. // time, so the page visibly reshuffles between reloads for no reason.
func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) { func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string) ([]songStat, error) {
rows, err := a.pool.Query(ctx, ` 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 from songs s join reviews r on r.song_id = s.id
group by s.id group by s.id
having count(r.id) >= $1 having count(r.id) >= $1
@@ -61,7 +74,8 @@ func (a *app) songLeaderboard(ctx context.Context, valueExpr, direction string)
var out []songStat var out []songStat
for rows.Next() { for rows.Next() {
var s songStat 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 return nil, err
} }
out = append(out, s) out = append(out, s)
+5 -7
View File
@@ -14,21 +14,19 @@
{{range .Data.Invites}} {{range .Data.Invites}}
<tr> <tr>
<td> <td>
{{if .IsValid}}
<a href="{{.Link}}">{{.Link}}</a> <a href="{{.Link}}">{{.Link}}</a>
{{else}}
<code class="muted">{{.Code}}</code>
{{end}}
</td> </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> <td>{{fidate .CreatedAt}}</td>
</tr> </tr>
{{else}} {{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}} {{end}}
</tbody> </tbody>
</table> </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> </div>
</section> </section>
+5 -3
View File
@@ -8,6 +8,7 @@
<link rel="preload" href="/static/fonts/oswald.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/oswald.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/player.js" defer></script>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -19,7 +20,7 @@
<span></span> <span></span>
{{else if .Member}} {{else if .Member}}
<nav class="navlinks"> <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="/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="/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> <a href="/stats" {{if eq .Path "/stats"}}aria-current="page"{{end}}>Tilastot</a>
@@ -39,7 +40,7 @@
<details class="mobilenav"> <details class="mobilenav">
<summary aria-label="Valikko"></summary> <summary aria-label="Valikko"></summary>
<div class="panel"> <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="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a> <a href="/submit">Lähetä</a>
<a href="/stats">Tilastot</a> <a href="/stats">Tilastot</a>
@@ -62,7 +63,8 @@
<!-- The server already knows where they were, so the path travels in the link — no JS. --> <!-- The server already knows where they were, so the path travels in the link — no JS. -->
<a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> · <a href="/report?from={{.Path}}">Ilmoita ongelmasta</a> ·
{{end}} {{end}}
Levyraati — kymmenen kaverin levyraati. <span class="slogan">We know good music, baby!</span>
<span class="copyright">© Kessinen</span>
</footer> </footer>
{{with .Flash}} {{with .Flash}}
+2
View File
@@ -1,5 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>Kirjaudu</h1> <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}} {{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
+16 -11
View File
@@ -1,10 +1,15 @@
{{define "player"}} {{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> <audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
</div>
{{end}} {{end}}
{{define "songcard"}} {{define "songcard"}}
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}"> <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="title">{{.Title}}</span>
<span class="artist">{{.Artist}}</span> <span class="artist">{{.Artist}}</span>
<span class="meta"> <span class="meta">
@@ -19,15 +24,15 @@
</a> </a>
{{end}} {{end}}
{{define "scorefield"}} {{define "fader"}}
<label>Pisteet <!-- A native range input turned vertical: keyboard, form submission and the value all stay free. -->
<span class="scorerow"> <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="{{.}}" <input type="range" name="score" min="1" max="100" value="{{.}}"
oninput="this.nextElementSibling.value = this.value"> aria-label="Pisteet 1100"
<output>{{.}}</output> oninput="this.closest('.fader').querySelector('output').value = this.value">
</span> <output class="readout">{{.}}</output>
</label> </div>
<span class="scorescale" aria-hidden="true">
<span>1</span><span>25</span><span>50</span><span>75</span><span>100</span>
</span>
{{end}} {{end}}
+24 -2
View File
@@ -15,8 +15,30 @@
<div class="statgrid"> <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.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">{{$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> </div>
{{if $p.Own}} {{if $p.Own}}
+7 -3
View File
@@ -7,9 +7,13 @@
<div class="songgrid"> <div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}} {{range .Data.Items}}{{template "songcard" .}}{{end}}
</div> </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}} {{else}}
<p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p> <p class="empty">Kaikki kuunneltu.</p>
<p><a href="/submit">Lähetä kappale</a> tai selaa <a href="/songs">kaikkia kappaleita</a>.</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}}
{{end}} {{end}}
+65 -26
View File
@@ -5,15 +5,10 @@
<strong>{{$s.Artist}}</strong> <strong>{{$s.Artist}}</strong>
<span class="badge genre">{{$s.GenreLabel}}</span> <span class="badge genre">{{$s.GenreLabel}}</span>
<span>{{$s.Length}}</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}} {{if $s.Own}}<span class="badge admin">oma kappale</span>{{end}}
</p> </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}} {{if $s.CanEdit}}
<details class="editbox"> <details class="editbox">
<summary>Muokkaa tietoja</summary> <summary>Muokkaa tietoja</summary>
@@ -33,7 +28,7 @@
</form> </form>
<form method="post" action="/songs/{{$s.ID}}/delete" <form method="post" action="/songs/{{$s.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')"> 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> </form>
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p> <p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
</details> </details>
@@ -41,51 +36,87 @@
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p> <p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
{{end}} {{end}}
<section>
{{if $s.CanReview}} {{if $s.CanReview}}
<h2>Arvostele</h2> <!-- The channel strip: the fader is the score, the panel beside it is everything else you do
<form method="post" action="/songs/{{$s.ID}}/review" class="stack"> while the track plays. -->
{{template "scorefield" 50}} <form method="post" action="/songs/{{$s.ID}}/review" class="strip">
<label>Arvostelu {{template "fader" 50}}
<textarea name="text" rows="6" maxlength="5000" required <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> placeholder="Mitä kuulit?"></textarea>
</label> </label>
<div class="deckfoot">
<button type="submit">Tallenna arvostelu</button> <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> </form>
<p class="muted small">Muiden arvostelut ja pisteet paljastuvat kun olet tallentanut omasi. {{else}}
Voit muokata tai poistaa arvostelusi 30 minuutin ajan.</p> {{template "player" $s}}
{{else if $s.ViewerReview}} {{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> <h2>Oma arvostelusi</h2>
{{with $s.ViewerReview}} {{with $s.ViewerReview}}
{{if .CanEdit}} {{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}}"> <input type="hidden" name="from" value="/songs/{{$s.ID}}">
{{template "scorefield" .Score}} {{template "fader" .Score}}
<label>Arvostelu <div class="deck">
<label class="grow">Arvostelu
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea> <textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
</label> </label>
<div class="deckfoot">
<button type="submit">Päivitä</button> <button type="submit">Päivitä</button>
<span class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</span>
</div>
</div>
</form> </form>
<form method="post" action="/reviews/{{.ID}}/delete" <form method="post" action="/reviews/{{.ID}}/delete"
onsubmit="return confirm('Poistetaanko arvostelu?')"> onsubmit="return confirm('Poistetaanko arvostelu?')">
<input type="hidden" name="from" value="/songs/{{$s.ID}}"> <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> </form>
<p class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</p>
{{else}} {{else}}
<p class="score">{{.Score}}</p> <p class="score">{{.Score}}</p>
<p>{{.Text}}</p> <p class="reviewtext">{{.Text}}</p>
<p class="muted small">Muokkausaika on päättynyt.</p> <p class="muted small">Muokkausaika on päättynyt.</p>
{{end}} {{end}}
{{end}} {{end}}
{{end}}
</section> </section>
{{end}}
<section> <section>
<h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2> <h2>Arvostelut{{if $s.ReviewCount}} ({{$s.ReviewCount}}){{end}}</h2>
{{if $s.Revealed}} {{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}} {{range $s.Reviews}}
<article class="review{{if .Own}} own{{end}}"> <article class="review{{if .Own}} own{{end}}">
<header> <header>
@@ -96,12 +127,20 @@
</header> </header>
<p>{{.Text}}</p> <p>{{.Text}}</p>
</article> </article>
{{end}}
{{else}} {{else}}
<p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p> <p class="muted">Kukaan ei ole vielä arvostellut tätä kappaletta.</p>
{{end}} {{end}}
{{else}} {{else}}
<p class="muted">Muiden arvostelut ja keskiarvo näkyvät kun olet kirjoittanut omasi. <p class="sealed-note">
{{if $s.ReviewCount}}Arvosteluja on {{$s.ReviewCount}}.{{end}}</p> <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}} {{end}}
</section> </section>
{{if $s.NextInQueue}}
<p class="nextup"><a href="/songs/{{$s.NextInQueue}}">Seuraava jonossa →</a></p>
{{end}}
{{end}} {{end}}
+4 -1
View File
@@ -5,7 +5,10 @@
<div class="songgrid"> <div class="songgrid">
{{range .Data.Items}}{{template "songcard" .}}{{end}} {{range .Data.Items}}{{template "songcard" .}}{{end}}
</div> </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}} {{else}}
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p> <p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
<p><a href="/submit">Lähetä ensimmäinen</a>.</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="muted small">{{.Artist}}</span>
<span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span> <span class="value">{{if $.Count}}{{.ReviewCount}}{{else}}{{value .Value}}{{end}}</span>
{{if not $.Count}}<span class="muted small">{{.ReviewCount}} arv.</span>{{end}} {{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> </li>
{{end}} {{end}}
</ol> </ol>
@@ -28,6 +36,7 @@
<a href="/profile/{{.ID}}">{{.Name}}</a> <a href="/profile/{{.ID}}">{{.Name}}</a>
<span class="value">{{if $.Count}}{{.Count}}{{else}}{{value .Value}}{{end}}</span> <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="muted small">{{.Count}} kpl</span>{{end}}
{{if not $.Count}}<span class="bar"><span class="fill" style="width: {{value .Value}}%"></span></span>{{end}}
</li> </li>
{{end}} {{end}}
</ol> </ol>
@@ -45,8 +54,8 @@
<div class="boards"> <div class="boards">
{{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}} {{template "songboard" dict "Title" "Parhaat" "Items" .Data.TopSongs}}
{{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}} {{template "songboard" dict "Title" "Heikoimmat" "Items" .Data.BottomSongs}}
{{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive}} {{template "songboard" dict "Title" "Riitaisimmat" "Items" .Data.MostDivisive "Spread" true}}
{{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified}} {{template "songboard" dict "Title" "Yksimielisimmät" "Items" .Data.MostUnified "Spread" true}}
{{template "songboard" dict "Title" "Eniten arvosteltu" "Items" .Data.MostReviewed "Count" 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" "Ankarin arvostelija" "Items" .Data.Harshest}}
{{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}} {{template "userboard" dict "Title" "Anteliain" "Items" .Data.MostGenerous}}
+6
View File
@@ -1,5 +1,11 @@
{{define "submission-status"}} {{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}}> <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> <p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}} {{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}} {{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}