Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to come along: //go:embed cannot reach outside its own directory, so templates/, static/ and migrations/ live beside the code that embeds them, and testdata/ beside the test that reads it. storage/ stays put — runtime data, not source. go build now needs -o. Without it the output would be named after the package directory and collide with src/ itself.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
{{define "content"}}
|
||||
<h1>Ylläpito</h1>
|
||||
<p><a href="/admin/reports">Palautteet</a>{{if .Data.OpenCount}} <span class="badge pending">{{.Data.OpenCount}} avointa</span>{{end}}</p>
|
||||
|
||||
<section class="adminsection">
|
||||
<header>
|
||||
<h2>Kutsut</h2>
|
||||
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
|
||||
</header>
|
||||
<div class="body">
|
||||
<table>
|
||||
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Invites}}
|
||||
<tr>
|
||||
<!-- Not a link: an invite is something to send, never to follow. A click used to open the
|
||||
join form in the admin's own browser, which is never what was wanted. -->
|
||||
<td class="invitecell">
|
||||
<code>{{.Link}}</code>
|
||||
<button type="button" class="ghost" onclick="copyInvite(this)">Kopioi</button>
|
||||
</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 käyttämättömiä kutsuja.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted small">Lähetä linkki kaverille. Koodi on 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>
|
||||
|
||||
<section class="adminsection">
|
||||
<header><h2>Jäsenet</h2></header>
|
||||
<div class="body">
|
||||
<table>
|
||||
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Members}}
|
||||
<tr{{if .Banned}} class="banned"{{end}}>
|
||||
<td>{{.Name}}{{if .Banned}} <span class="badge pending">estetty</span>{{end}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{fidate .CreatedAt}}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/users/{{.ID}}/ban">
|
||||
<button type="submit" class="ghost">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{.ID}}/password">
|
||||
<input type="password" name="password" placeholder="uusi salasana" required>
|
||||
<button type="submit" class="ghost">Vaihda salasana</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="adminsection">
|
||||
<header><h2>Kappaleet</h2></header>
|
||||
<div class="body">
|
||||
<table>
|
||||
<thead><tr><th>Kappale</th><th>Lähettäjä</th><th>Arvostelut</th><th>Julkaistu</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Songs}}
|
||||
<tr>
|
||||
<td>{{.Title}} <span class="muted">— {{.Artist}}</span></td>
|
||||
<td>{{.Submitter}}</td>
|
||||
<td>{{.Reviews}}</td>
|
||||
<td class="nowrap">{{fidate .CreatedAt}}</td>
|
||||
<td class="actions">
|
||||
<a href="/audio/{{.ID}}">Kuuntele</a>
|
||||
<form method="post" action="/admin/songs/{{.ID}}/delete"
|
||||
onsubmit="return confirm('Poistetaanko kappale ja kaikki sen arvostelut?')">
|
||||
<button type="submit" class="ghost danger">Poista</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="muted">Ei kappaleita.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// The clipboard API needs a secure context. Over the documented SSH tunnel the origin is
|
||||
// localhost, which qualifies; reached any other way it is missing, so selecting the text is the
|
||||
// fallback — the admin presses Ctrl+C instead of being left with a button that does nothing.
|
||||
function copyInvite(button) {
|
||||
const link = button.previousElementSibling;
|
||||
const done = () => {
|
||||
button.textContent = 'Kopioitu';
|
||||
setTimeout(() => { button.textContent = 'Kopioi'; }, 1500);
|
||||
};
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(link.textContent).then(done, () => selectText(link));
|
||||
} else {
|
||||
selectText(link);
|
||||
}
|
||||
}
|
||||
|
||||
function selectText(el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "content"}}
|
||||
<h1>Palautteet</h1>
|
||||
<p><a href="/admin">← Ylläpito</a></p>
|
||||
|
||||
{{range .Data}}
|
||||
<article class="review{{if .Open}} own{{end}}">
|
||||
<header>
|
||||
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
|
||||
<span class="who">{{.Reporter}}</span>
|
||||
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
|
||||
</header>
|
||||
<p>{{.Body}}</p>
|
||||
<p class="meta break">{{.UserAgent}}</p>
|
||||
{{if .Open}}
|
||||
<form method="post" action="/admin/reports/{{.ID}}/resolve">
|
||||
<button type="submit" class="ghost">Merkitse käsitellyksi</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</article>
|
||||
{{else}}
|
||||
<p class="empty">Ei palautteita.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<html lang="fi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} — Levyraati</title>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<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>
|
||||
<script src="/static/lyrics.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
|
||||
|
||||
{{if .Member}}
|
||||
<nav class="navlinks">
|
||||
<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>
|
||||
<!-- An admin is a member first: the same nav, with one link the others do not get. -->
|
||||
{{if .Member.IsAdmin}}<a href="/admin" {{if .Admin}}aria-current="page"{{end}}>Ylläpito</a>{{end}}
|
||||
</nav>
|
||||
<div class="userblock">
|
||||
<span class="lines">
|
||||
<span class="name">{{.Member.Name}}</span>
|
||||
<span class="email">{{.Member.Email}}</span>
|
||||
</span>
|
||||
<a href="/profile" title="{{.Member.Name}}">
|
||||
{{if .Member.Avatar}}<img class="avatar" src="/avatars/{{.Member.ID}}" alt="Oma profiili">
|
||||
{{else}}<span class="avatar">{{.Member.Initials}}</span>{{end}}
|
||||
</a>
|
||||
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
|
||||
|
||||
<!-- <details> is the mobile panel: no JS, and Esc/click-away come free. -->
|
||||
<details class="mobilenav">
|
||||
<summary aria-label="Valikko">☰</summary>
|
||||
<div class="panel">
|
||||
<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>
|
||||
{{if .Member.IsAdmin}}<a href="/admin">Ylläpito</a>{{end}}
|
||||
<a href="/profile">Oma profiili</a>
|
||||
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{{else}}
|
||||
<span></span>
|
||||
<nav class="navlinks"><a href="/login">Kirjaudu</a></nav>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
|
||||
|
||||
<footer class="sitefooter">
|
||||
{{if .Member}}
|
||||
<!-- The server already knows where they were, so the path travels in the link — no JS. -->
|
||||
<a href="/report?from={{.Path}}">Anna palautetta</a> ·
|
||||
{{end}}
|
||||
<span class="slogan">We know good music, baby!</span>
|
||||
<span class="copyright">© Kessinen</span>
|
||||
<span class="version" title="Käytössä oleva versio">v{{.Version}}</span>
|
||||
</footer>
|
||||
|
||||
{{with .Flash}}
|
||||
<div class="toasts">
|
||||
<div class="toast" role="status">
|
||||
<p>{{.}}</p>
|
||||
<button class="dismiss" aria-label="Sulje"
|
||||
onclick="this.closest('.toast').remove()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{{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}}
|
||||
|
||||
<form method="post" action="/login" class="stack">
|
||||
<label>Sähköposti
|
||||
<input type="email" name="email" value="{{.Data.Email}}" required autofocus autocomplete="email">
|
||||
</label>
|
||||
<label>Salasana
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" name="remember" value="1"> Pysy kirjautuneena 30 päivää
|
||||
</label>
|
||||
<button type="submit">Kirjaudu</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan
|
||||
<a href="/register">tästä</a>.</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{/* Two shapes, because timed lyrics and guessed lyrics deserve different treatment.
|
||||
Synced: one element per line with its own timestamp, highlighted as it comes.
|
||||
Plain: one block that scrolls continuously, with a nudge knob, because a highlight on evenly
|
||||
guessed timings turns guaranteed drift into what looks like a bug. */}}
|
||||
{{define "lyricsview"}}
|
||||
<span class="cap">Sanoitukset</span>
|
||||
{{$lines := .LyricLines}}
|
||||
{{if $lines}}
|
||||
<div class="lyricsbox synced" data-song="{{.ID}}">
|
||||
{{range $lines}}<p class="lline" data-t="{{.At}}">{{if .Text}}{{.Text}}{{else}} {{end}}</p>{{end}}
|
||||
</div>
|
||||
<label class="follow">
|
||||
<input type="checkbox" checked> Seuraa kappaletta
|
||||
<span class="muted small">(sivu ei vieri)</span>
|
||||
</label>
|
||||
{{else}}
|
||||
<div class="lyricsbox plain" data-duration="{{.Duration}}" data-song="{{.ID}}">
|
||||
<div class="lscroll">{{lyricstext .Lyrics}}</div>
|
||||
</div>
|
||||
<label class="nudge">
|
||||
Ajoitus
|
||||
<input type="range" min="-10" max="10" step="0.5" value="0" aria-label="Ajoituksen siirto sekunteina">
|
||||
<output>0 s</output>
|
||||
</label>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "player"}}
|
||||
<!-- Ships with native controls; player.js removes them and drives the same element. No JS means
|
||||
the browser's own player, which is plain but complete. -->
|
||||
<div class="playerwrap" data-duration="{{.Duration}}">
|
||||
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "songcard"}}
|
||||
<a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
|
||||
{{if .Average}}<span class="scorebadge">{{score .Average}}</span>
|
||||
{{else if .ReviewCount}}<span class="scorebadge sealed" title="Muiden pisteet paljastuvat kun tallennat omasi"></span>{{end}}
|
||||
<span class="title">{{.Title}}</span>
|
||||
<span class="artist">{{.Artist}}</span>
|
||||
<span class="meta">
|
||||
<span class="badge genre">{{.GenreLabel}}</span>
|
||||
<span>{{.Length}}</span>
|
||||
<span>{{.Submitter}}</span>
|
||||
{{if .Own}}<span class="badge admin">oma</span>
|
||||
{{else if .Reviewed}}<span class="badge reviewed">arvosteltu</span>
|
||||
{{else}}<span class="badge pending">arvostelematta</span>{{end}}
|
||||
{{if .ReviewCount}}<span>{{.ReviewCount}} arvostelua</span>{{end}}
|
||||
</span>
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{define "fader"}}
|
||||
<!-- A native range input turned vertical: keyboard, form submission and the value all stay free. -->
|
||||
<div class="fader">
|
||||
<span class="ticks" aria-hidden="true">
|
||||
<span>100</span><span>75</span><span>50</span><span>25</span><span>1</span>
|
||||
</span>
|
||||
<input type="range" name="score" min="1" max="100" value="{{.}}"
|
||||
aria-label="Pisteet 1–100"
|
||||
oninput="this.closest('.fader').querySelector('output').value = this.value">
|
||||
<output class="readout">{{.}}</output>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,67 @@
|
||||
{{define "content"}}
|
||||
{{$p := .Data}}
|
||||
<div class="profilehead">
|
||||
{{if $p.Avatar}}
|
||||
<img class="avatar big" src="/avatars/{{$p.ID}}" alt="">
|
||||
{{else}}
|
||||
<span class="avatar big">{{$p.Initials}}</span>
|
||||
{{end}}
|
||||
<div>
|
||||
<h1>{{$p.Name}}</h1>
|
||||
<p class="muted">Liittyi {{fidate $p.CreatedAt}}{{if $p.Email}} · {{$p.Email}}{{end}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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}}
|
||||
<details class="editbox">
|
||||
<summary>Muokkaa tietoja</summary>
|
||||
<form method="post" action="/profile" enctype="multipart/form-data" class="stack">
|
||||
<label>Nimi <input name="name" value="{{$p.Name}}" maxlength="50" required></label>
|
||||
<label>Sähköposti <input type="email" name="email" value="{{$p.Email}}" required></label>
|
||||
<label>Kuva <input type="file" name="avatar" accept="image/*"></label>
|
||||
<label>Nykyinen salasana <input type="password" name="current_password" autocomplete="current-password"></label>
|
||||
<label>Uusi salasana <input type="password" name="new_password" autocomplete="new-password"></label>
|
||||
<button type="submit">Tallenna</button>
|
||||
</form>
|
||||
<p class="muted small">Salasanan vaihto vaatii nykyisen salasanan ja kirjaa ulos muut laitteesi.</p>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
<section>
|
||||
<h2>Kappaleet</h2>
|
||||
{{if $p.Songs}}
|
||||
<div class="songgrid">{{range $p.Songs}}{{template "songcard" .}}{{end}}</div>
|
||||
{{else}}
|
||||
<p class="muted">Ei vielä yhtään kappaletta.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "content"}}
|
||||
<h1>Jono</h1>
|
||||
|
||||
{{if .Data.Items}}
|
||||
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Muiden pisteet paljastuvat
|
||||
kun tallennat omasi.</p>
|
||||
<div class="songgrid">
|
||||
{{range .Data.Items}}{{template "songcard" .}}{{end}}
|
||||
</div>
|
||||
<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">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}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content"}}
|
||||
<h1>Liity</h1>
|
||||
|
||||
<form method="post" action="/register" class="stack">
|
||||
<label>Kutsukoodi
|
||||
<input name="code" value="{{.Data.Code}}" required>
|
||||
{{with .Data.Errors.code}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<label>Nimi
|
||||
<input name="name" value="{{.Data.Name}}" required maxlength="50">
|
||||
{{with .Data.Errors.name}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<label>Sähköposti
|
||||
<input type="email" name="email" value="{{.Data.Email}}" required autocomplete="email">
|
||||
{{with .Data.Errors.email}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<label>Salasana
|
||||
<input type="password" name="password" required autocomplete="new-password">
|
||||
{{with .Data.Errors.password}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<button type="submit">Liity</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{define "content"}}
|
||||
<h1>Palaute</h1>
|
||||
<p class="muted">Ongelmat, ideat ja kaikki muu palaute samaan paikkaan. Yksi virke riittää.</p>
|
||||
|
||||
<form method="post" action="/report" class="stack">
|
||||
<input type="hidden" name="from" value="{{.Data.From}}">
|
||||
<label>Palaute
|
||||
<textarea name="body" rows="6" maxlength="2000" required autofocus
|
||||
placeholder="Esim. soittimeen kaipaisi kelausta."></textarea>
|
||||
</label>
|
||||
<button type="submit">Lähetä palaute</button>
|
||||
</form>
|
||||
<p class="muted small">Lähetämme mukaan sivun, jolla olit ({{.Data.From}}), sekä selaimen tiedot.</p>
|
||||
|
||||
{{with .Data.Mine}}
|
||||
<section>
|
||||
<h2>Omat palautteet</h2>
|
||||
{{range .}}
|
||||
<article class="review{{if .Open}} own{{end}}">
|
||||
<header>
|
||||
<span class="badge {{if .Open}}pending{{else}}reviewed{{end}}">{{if .Open}}avoin{{else}}käsitelty{{end}}</span>
|
||||
<span class="meta">{{fidate .CreatedAt}}{{if .Page}} · {{.Page}}{{end}}</span>
|
||||
</header>
|
||||
<p>{{.Body}}</p>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,180 @@
|
||||
{{define "content"}}
|
||||
{{$s := .Data}}
|
||||
<h1>{{$s.Title}}</h1>
|
||||
<p class="by">{{$s.Artist}}</p>
|
||||
|
||||
<!-- Four different kinds of fact, so four labelled cells rather than one run of text. "Oma
|
||||
kappale" belongs here as the submitter, not as a badge: it is a fact about you. -->
|
||||
<dl class="spec">
|
||||
<div><dt>Genre</dt><dd>{{$s.GenreLabel}}</dd></div>
|
||||
<div><dt>Kesto</dt><dd>{{$s.Length}}</dd></div>
|
||||
<div><dt>Lähetti</dt>
|
||||
<dd>{{if $s.Own}}sinä{{else}}<a href="/profile/{{$s.SubmitterID}}">{{$s.Submitter}}</a>{{end}}</dd>
|
||||
</div>
|
||||
<div><dt>Julkaistu</dt><dd>{{fiday $s.CreatedAt}}</dd></div>
|
||||
</dl>
|
||||
|
||||
{{if $s.CanEdit}}
|
||||
<details class="editbox">
|
||||
<summary>Muokkaa tietoja</summary>
|
||||
<form method="post" action="/songs/{{$s.ID}}" class="stack">
|
||||
<label>Nimi <input name="title" value="{{$s.Title}}" maxlength="100" required></label>
|
||||
<label>Esittäjä <input name="artist" value="{{$s.Artist}}" maxlength="100" required></label>
|
||||
<label>Genre
|
||||
<select name="genre" required>
|
||||
{{$current := $s.Genre}}
|
||||
{{range $s.Genres}}
|
||||
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Esittely <textarea name="description" rows="4" maxlength="2000">{{$s.Description}}</textarea></label>
|
||||
<button type="submit">Tallenna</button>
|
||||
</form>
|
||||
<form method="post" action="/songs/{{$s.ID}}/delete"
|
||||
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
|
||||
<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>
|
||||
{{else if $s.Own}}
|
||||
<p class="muted small">Kappaletta on jo arvosteltu, joten tietoja ei voi enää muuttaa.</p>
|
||||
{{end}}
|
||||
|
||||
{{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}}
|
||||
|
||||
<!-- Two panes when the song has lyrics: read on the left, write on the right, so following
|
||||
the words costs no scrolling. Without lyrics the pane is absent, not empty. -->
|
||||
<div class="panes{{if not $s.Lyrics}} solo{{end}}">
|
||||
{{if $s.Lyrics}}
|
||||
<div class="lyricspane">{{template "lyricsview" $s}}</div>
|
||||
{{end}}
|
||||
<div class="writepane">
|
||||
<label class="grow">Arvostelu
|
||||
<textarea name="text" maxlength="5000" required placeholder="Mitä kuulit?"></textarea>
|
||||
</label>
|
||||
<div class="deckfoot">
|
||||
<button type="submit">Tallenna arvostelu</button>
|
||||
<span class="muted small">Muiden pisteet paljastuvat kun tallennat omasi. Voit muokata
|
||||
tai poistaa arvostelusi 30 minuutin ajan.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
{{template "player" $s}}
|
||||
{{with $s.Description}}<p class="intro">{{.}}</p>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* New tab: leaving the page mid-review would lose whatever is already typed in the form. */}}
|
||||
{{with $s.SourceURL}}<p class="muted small"><a href="{{.}}" target="_blank" rel="noreferrer">Kuuntele YouTubessa</a></p>{{end}}
|
||||
|
||||
{{if and (not $s.CanReview) (or $s.Lyrics $s.Own)}}
|
||||
<!-- Only when the review strip is not already showing them: while reviewing, the lyrics live in
|
||||
the left pane. This panel is for reading afterwards and for the submitter's edits. -->
|
||||
<details class="lyrics">
|
||||
<summary>Sanoitukset{{if not $s.Lyrics}} <span class="muted small">(ei vielä lisätty)</span>{{end}}</summary>
|
||||
{{if $s.Lyrics}}<pre class="lyricstext">{{lyricstext $s.Lyrics}}</pre>{{end}}
|
||||
{{if $s.Own}}
|
||||
<form method="post" action="/songs/{{$s.ID}}/lyrics" class="stack">
|
||||
<label>Muokkaa sanoituksia
|
||||
<textarea name="lyrics" rows="10" maxlength="20000"
|
||||
placeholder="Liitä sanoitukset tähän.">{{$s.Lyrics}}</textarea>
|
||||
</label>
|
||||
<button type="submit">Tallenna sanoitukset</button>
|
||||
</form>
|
||||
<p class="muted small">Sanoituksia voi muokata vielä arvostelujenkin jälkeen.</p>
|
||||
{{end}}
|
||||
</details>
|
||||
{{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>
|
||||
<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.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="sealed-note">
|
||||
<span class="sealed" aria-hidden="true"></span>
|
||||
Muiden pisteet paljastuvat kun tallennat 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}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{{define "content"}}
|
||||
<h1>Kappaleet</h1>
|
||||
|
||||
{{if .Data.Items}}
|
||||
<div class="songgrid">
|
||||
{{range .Data.Items}}{{template "songcard" .}}{{end}}
|
||||
</div>
|
||||
<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>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{define "songboard"}}
|
||||
<section class="board">
|
||||
<h2>{{.Title}}</h2>
|
||||
{{if .Items}}
|
||||
<ol class="board-list">
|
||||
{{range .Items}}
|
||||
<li>
|
||||
<a href="/songs/{{.ID}}">{{.Title}}</a>
|
||||
<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>
|
||||
{{else}}
|
||||
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "userboard"}}
|
||||
<section class="board">
|
||||
<h2>{{.Title}}</h2>
|
||||
{{if .Items}}
|
||||
<ol class="board-list">
|
||||
{{range .Items}}
|
||||
<li>
|
||||
<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>
|
||||
{{else}}
|
||||
<p class="muted small">Ei vielä tarpeeksi arvosteluja.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Tilastot</h1>
|
||||
<p class="muted">Kappale pääsee listoille kun sillä on vähintään {{.Data.MinReviews}} arvostelua.
|
||||
Tilastot näkyvät kaikille.</p>
|
||||
|
||||
<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 "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}}
|
||||
{{template "userboard" dict "Title" "Ahkerin arvostelija" "Items" .Data.MostActive "Count" true}}
|
||||
{{template "userboard" dict "Title" "Ahkerin lähettäjä" "Items" .Data.MostProlific "Count" true}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,91 @@
|
||||
{{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}}
|
||||
<div class="actions">
|
||||
{{if .CanRetry}}
|
||||
<form method="post" action="/submit/{{.ID}}/retry">
|
||||
<button type="submit">Yritä uudelleen</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/submit/{{.ID}}/discard">
|
||||
<button type="submit" class="danger">Poista lähetys</button>
|
||||
</form>
|
||||
</div>
|
||||
{{if not .CanRetry}}<p class="muted small">Lataa tiedosto uudelleen, jos haluat yrittää toisen kerran.</p>{{end}}
|
||||
{{else}}
|
||||
<!-- Outside the form and attached to it with form=, so one button both submits the metadata
|
||||
and publishes. Disabled until the audio has finished converting. -->
|
||||
<button type="submit" form="meta" {{if not .Ready}}disabled{{end}}>Julkaise</button>
|
||||
{{if not .Ready}}<p class="muted small">Julkaise aukeaa kun lähetys on valmis.</p>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "saved"}}<span id="saved" class="saved">{{if .}}Tallennettu {{.}}{{end}}</span>{{end}}
|
||||
|
||||
<!-- Included by the page and returned alone by the suggestion button, so the markup exists once.
|
||||
hx-include sends the current title and artist, which is the whole point: the lookup uses what
|
||||
the submitter just fixed, not what the tags claimed. -->
|
||||
{{define "lyricsfield"}}
|
||||
<div id="lyricsfield">
|
||||
<label>Sanoitukset <span class="muted small">(vapaaehtoinen)</span>
|
||||
<textarea name="lyrics" rows="8" maxlength="20000"
|
||||
placeholder="Liitä sanoitukset tähän tai hae ne alta.">{{.Lyrics}}</textarea>
|
||||
</label>
|
||||
<div class="lyricsbar">
|
||||
<button type="button" class="ghost"
|
||||
hx-post="/submit/{{.ID}}/lyrics"
|
||||
hx-include="[name='title'], [name='artist']"
|
||||
hx-target="#lyricsfield" hx-swap="outerHTML">Hae sanoitukset</button>
|
||||
{{if .Found}}<span class="muted small">Löytyi. Tarkista teksti.</span>{{end}}
|
||||
{{if .Searched}}{{if not .Found}}
|
||||
<span class="muted small">Ei löytynyt. Tarkista nimi ja esittäjä tai liitä sanoitukset itse.</span>
|
||||
{{end}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Lähetys</h1>
|
||||
|
||||
{{if .Data.Failed}}{{template "submission-status" .Data}}{{end}}
|
||||
|
||||
{{if not .Data.Failed}}
|
||||
<form id="meta" method="post" action="/submit/{{.Data.ID}}/publish" class="stack"
|
||||
hx-post="/submit/{{.Data.ID}}" hx-trigger="input changed delay:1.2s, change"
|
||||
hx-target="#saved" hx-swap="outerHTML">
|
||||
<label>Kappaleen nimi
|
||||
<input name="title" value="{{.Data.Title}}" maxlength="100" required>
|
||||
</label>
|
||||
<label>Esittäjä
|
||||
<input name="artist" value="{{.Data.Artist}}" maxlength="100" required>
|
||||
</label>
|
||||
<label>Genre
|
||||
<select name="genre" required>
|
||||
<option value="">— valitse —</option>
|
||||
{{$current := .Data.Genre}}
|
||||
{{range .Data.Genres}}
|
||||
<option value="{{.Code}}" {{if eq .Code $current}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Esittely <span class="muted small">(vapaaehtoinen)</span>
|
||||
<textarea name="description" rows="5" maxlength="2000">{{.Data.Description}}</textarea>
|
||||
</label>
|
||||
{{template "lyricsfield" dict "ID" .Data.ID "Lyrics" .Data.Lyrics}}
|
||||
{{template "saved" ""}}
|
||||
</form>
|
||||
|
||||
<p class="muted">Nimi, esittäjä ja genre tarvitaan ennen julkaisua.</p>
|
||||
|
||||
{{template "submission-status" .Data}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{define "content"}}
|
||||
<h1>Lähetä kappale</h1>
|
||||
|
||||
{{with .Data.Error}}<p class="error">{{.}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/submit" enctype="multipart/form-data" class="stack">
|
||||
<label class="dropzone" id="dropzone" for="audio">
|
||||
<input type="file" id="audio" name="audio" accept="audio/*">
|
||||
<span class="dz-title">Raahaa äänitiedosto tähän</span>
|
||||
<span class="muted small">tai valitse napsauttamalla</span>
|
||||
<span class="filename" id="filename"></span>
|
||||
</label>
|
||||
<p class="or">tai</p>
|
||||
<label>YouTube-linkki
|
||||
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=…">
|
||||
</label>
|
||||
<button type="submit">Lähetä</button>
|
||||
</form>
|
||||
|
||||
{{with .Data.Submissions}}
|
||||
<section>
|
||||
<h2>Omat lähetykset</h2>
|
||||
<table>
|
||||
<thead><tr><th>Kappale</th><th>Tila</th><th>Lähetetty</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td><a href="/submit/{{.ID}}">{{if .Title}}{{.Title}}{{else}}(nimetön){{end}}</a></td>
|
||||
<td>{{.Label}}</td>
|
||||
<td class="nowrap">{{fidate .CreatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<p class="muted">Enintään 50 MB ja 15 minuuttia.</p>
|
||||
|
||||
<script>
|
||||
// The native control can't be relabelled or styled, so it is hidden behind this one — which
|
||||
// still is the input, so keyboard focus and form submission work untouched.
|
||||
(function () {
|
||||
const zone = document.getElementById('dropzone')
|
||||
const input = document.getElementById('audio')
|
||||
const name = document.getElementById('filename')
|
||||
|
||||
const show = () => {
|
||||
name.textContent = input.files.length ? input.files[0].name : ''
|
||||
zone.classList.toggle('has-file', input.files.length > 0)
|
||||
}
|
||||
input.addEventListener('change', show)
|
||||
|
||||
for (const type of ['dragenter', 'dragover']) {
|
||||
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.add('over') })
|
||||
}
|
||||
for (const type of ['dragleave', 'dragend', 'drop']) {
|
||||
zone.addEventListener(type, e => { e.preventDefault(); zone.classList.remove('over') })
|
||||
}
|
||||
zone.addEventListener('drop', e => {
|
||||
if (e.dataTransfer.files.length) {
|
||||
input.files = e.dataTransfer.files
|
||||
show()
|
||||
}
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user