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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,182 @@ 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); }
|
||||
|
||||
.copyright { color: var(--muted); }
|
||||
.copyright::before { content: "·"; margin: 0 var(--space-2); }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,21 +14,19 @@
|
||||
{{range .Data.Invites}}
|
||||
<tr>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
<a href="{{.Link}}">{{.Link}}</a>
|
||||
{{else}}
|
||||
<code class="muted">{{.Code}}</code>
|
||||
{{end}}
|
||||
<a href="{{.Link}}">{{.Link}}</a>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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,8 @@
|
||||
<!-- 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>
|
||||
<span class="copyright">© Kessinen</span>
|
||||
</footer>
|
||||
|
||||
{{with .Flash}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{{define "player"}}
|
||||
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
|
||||
<!-- 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">
|
||||
<input type="range" name="score" min="1" max="100" value="{{.}}"
|
||||
oninput="this.nextElementSibling.value = this.value">
|
||||
<output>{{.}}</output>
|
||||
{{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>
|
||||
</label>
|
||||
<span class="scorescale" aria-hidden="true">
|
||||
<span>1</span><span>25</span><span>50</span><span>75</span><span>100</span>
|
||||
</span>
|
||||
<input type="range" name="score" min="1" max="100" value="{{.}}"
|
||||
aria-label="Pisteet 1–100"
|
||||
oninput="this.closest('.fader').querySelector('output').value = this.value">
|
||||
<output class="readout">{{.}}</output>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
+24
-2
@@ -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,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}}
|
||||
|
||||
+91
-52
@@ -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,67 +36,111 @@
|
||||
<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
|
||||
{{if $s.CanReview}}
|
||||
<!-- 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>
|
||||
<button type="submit">Tallenna arvostelu</button>
|
||||
</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}}
|
||||
<h2>Oma arvostelusi</h2>
|
||||
{{with $s.ViewerReview}}
|
||||
{{if .CanEdit}}
|
||||
<form method="post" action="/reviews/{{.ID}}" class="stack">
|
||||
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
|
||||
{{template "scorefield" .Score}}
|
||||
<label>Arvostelu
|
||||
<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>
|
||||
{{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="strip">
|
||||
<input type="hidden" name="from" value="/songs/{{$s.ID}}">
|
||||
{{template "fader" .Score}}
|
||||
<div class="deck">
|
||||
<label class="grow">Arvostelu
|
||||
<textarea name="text" rows="6" maxlength="5000" required>{{.Text}}</textarea>
|
||||
</label>
|
||||
<button type="submit">Päivitä</button>
|
||||
</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>
|
||||
</form>
|
||||
<p class="muted small">Muokkausaika päättyy {{fidate .EditableUntil}}.</p>
|
||||
{{else}}
|
||||
<p class="score">{{.Score}}</p>
|
||||
<p>{{.Text}}</p>
|
||||
<p class="muted small">Muokkausaika on päättynyt.</p>
|
||||
{{end}}
|
||||
<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="ghost danger">Poista arvostelu</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="score">{{.Score}}</p>
|
||||
<p class="reviewtext">{{.Text}}</p>
|
||||
<p class="muted small">Muokkausaika on päättynyt.</p>
|
||||
{{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}}
|
||||
{{range $s.Reviews}}
|
||||
<article class="review{{if .Own}} own{{end}}">
|
||||
<header>
|
||||
<span class="avatar small">{{.Initials}}</span>
|
||||
<span class="who">{{.Reviewer}}</span>
|
||||
<span class="score">{{.Score}}</span>
|
||||
<span class="meta">{{fidate .CreatedAt}}</span>
|
||||
</header>
|
||||
<p>{{.Text}}</p>
|
||||
</article>
|
||||
{{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>
|
||||
<span class="avatar small">{{.Initials}}</span>
|
||||
<span class="who">{{.Reviewer}}</span>
|
||||
<span class="score">{{.Score}}</span>
|
||||
<span class="meta">{{fidate .CreatedAt}}</span>
|
||||
</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}}
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
Reference in New Issue
Block a user