Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b3bbbbd7b | ||
|
|
ac2cfaebac | ||
|
|
2e68feedfe | ||
|
|
cbae2448f9 | ||
|
|
f51dcd743e | ||
|
|
69eea8d707 | ||
|
|
f1e907bac3 | ||
|
|
f33f4fa4d6 |
+3
-1
@@ -1,9 +1,11 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
# CalVer, injected at build so no file needs bumping by hand: docker build --build-arg VERSION=…
|
||||
ARG VERSION=dev
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -o /levyraati .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" -o /levyraati .
|
||||
|
||||
FROM alpine:3.24
|
||||
# yt-dlp rots against YouTube. Alpine's active branch tracks it closely (3.24 carries the current
|
||||
|
||||
@@ -16,15 +16,30 @@ Invite-only, no public registration. Built for about ten friends.
|
||||
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish |
|
||||
| [docs/spec.md](docs/spec.md) | What the app does: rules, pipeline, routes, API contract, schema |
|
||||
| [docs/decisions.md](docs/decisions.md) | Why it is that way. Append-only |
|
||||
| [docs/theme.md](docs/theme.md) | The visual language: tokens, type, and what differs from the theme handoff |
|
||||
| [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
|
||||
|
||||
## Branches
|
||||
## Branches and releases
|
||||
|
||||
- **`main`** — released code only. Every commit on it is something that ran in production, or is
|
||||
meant to. Tagged at each release.
|
||||
- **`dev`** — current development, and whatever nightly builds get made. Work happens here and
|
||||
reaches `main` by merge at release time.
|
||||
|
||||
Versions are **CalVer: `YYYY.MM.DD-N`**, where `N` is the build number for that day, starting at 1.
|
||||
The version is injected at build time, so no file in the repo carries it:
|
||||
|
||||
```sh
|
||||
git switch main && git merge --no-ff dev
|
||||
git tag 2026.07.31-1
|
||||
VERSION=$(git describe --tags --exact-match) docker compose build app
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
A plain `go build` reports `dev`, which is the honest answer for a local binary. The running
|
||||
version appears in the footer, in the startup log line, and in `GET /healthz` — so "what is
|
||||
actually deployed" is answerable without an SSH session.
|
||||
|
||||
The app never sends email — there is no verification, no password reset link, and no notifications.
|
||||
Members have an address because it is their login and because mail is a planned feature.
|
||||
|
||||
|
||||
@@ -29,15 +29,25 @@ type adminMember struct {
|
||||
}
|
||||
|
||||
type dashboard struct {
|
||||
Invites []adminInvite
|
||||
Members []adminMember
|
||||
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
|
||||
@@ -77,6 +87,16 @@ func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if d.Songs, err = a.adminSongs(r.Context()); err != nil {
|
||||
adminError(w, "songs", err)
|
||||
return
|
||||
}
|
||||
if err := a.pool.QueryRow(r.Context(),
|
||||
`select count(*)::int from reports where resolved_at is null`).Scan(&d.OpenCount); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ type authForm struct {
|
||||
}
|
||||
|
||||
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Data: authForm{}})
|
||||
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}})
|
||||
}
|
||||
|
||||
func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -169,7 +169,7 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if a.logins.locked(email) {
|
||||
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
|
||||
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Data: form})
|
||||
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,12 +185,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
// One message for both cases: a distinct "no such account" tells anyone who asks which
|
||||
// addresses are members.
|
||||
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
|
||||
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Data: form})
|
||||
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||
return
|
||||
}
|
||||
if banned {
|
||||
form.Errors["form"] = "Tunnus on estetty."
|
||||
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Data: form})
|
||||
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func (a *app) logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
|
||||
a.render(w, r, http.StatusOK, "register.html",
|
||||
page{Title: "Liity", Data: authForm{Code: r.URL.Query().Get("code")}})
|
||||
page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}})
|
||||
}
|
||||
|
||||
// register spends the invite only when the account is actually created: both statements are in one
|
||||
@@ -248,7 +248,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
||||
form.Errors["code"] = "Kutsukoodi on pakollinen."
|
||||
}
|
||||
if len(form.Errors) > 0 {
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
||||
form.Code).Scan(&inviteID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("burn invite", "ctx", "invites", "error", err)
|
||||
@@ -288,7 +288,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
|
||||
if isUnique(err) {
|
||||
// Rolls back, so the invite is still valid.
|
||||
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Data: form})
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("create user", "ctx", "auth", "error", err)
|
||||
|
||||
+4
-1
@@ -17,7 +17,10 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
environment:
|
||||
DATABASE_URL: postgres://levyraati:${POSTGRES_PASSWORD}@postgres:5432/levyraati
|
||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||
|
||||
@@ -162,6 +162,13 @@ says so.
|
||||
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
|
||||
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
|
||||
`dev`.
|
||||
42. **The theme handoff is implemented as CSS custom properties, not a Tailwind config.** Its
|
||||
palette, spacing, shadows, motion and component shapes are followed as written; the parts that
|
||||
assumed Tailwind, Pico or cover artwork are adapted rather than dropped, and each adaptation is
|
||||
listed in [theme.md](./theme.md). Oswald's phantom weight 900 resolved to 700 — loading a weight
|
||||
you do not have is what made the brand render differently per platform. The custom audio player
|
||||
stays deferred: `color-scheme: dark` makes the native control fit the palette, which was the
|
||||
actual complaint.
|
||||
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
|
||||
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
|
||||
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
|
||||
@@ -171,3 +178,16 @@ says so.
|
||||
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
|
||||
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
|
||||
else out by trying.
|
||||
43. **The JSON API is deferred entirely, not built on demand.** Decision 17 kept the contract fixed
|
||||
and expected handlers to appear one at a time; in practice nothing consumes `/api` at all, so
|
||||
even that trickle would be handlers with no callers, plus golden tests guarding shapes nothing
|
||||
reads. The contract in [spec.md](./spec.md) stays as the design — it is what stops the shape
|
||||
changing under a future client — and the first endpoint gets built the day something actually
|
||||
calls it. Both surfaces being thin adapters over one data function is already true of the page
|
||||
handlers, so adding the JSON side later stays a one-line-per-route job.
|
||||
44. **CalVer, `YYYY.MM.DD-N`, injected at build time.** The date is the useful part: this app ships
|
||||
when there is something to ship and gets rebuilt monthly for yt-dlp anyway, so a semantic
|
||||
version would communicate nothing a date does not. `N` is the build number within that day,
|
||||
starting at 1, for the second attempt at a release. The string lives in a git tag and reaches
|
||||
the binary through `-ldflags`, so no file in the repo has to be bumped and a local build
|
||||
honestly reports `dev`. It surfaces in the footer, the startup log and `/healthz`.
|
||||
|
||||
@@ -130,6 +130,44 @@ Also here: pruning the count-based leaderboards once the queue has drained and t
|
||||
|
||||
---
|
||||
|
||||
## Review form as a mixer channel
|
||||
|
||||
Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**,
|
||||
with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form
|
||||
field and becomes the instrument the app is actually about, and the two things you do at once —
|
||||
decide a number, write why — stop being stacked a screenful apart.
|
||||
|
||||
Notes for whoever builds it:
|
||||
|
||||
- A vertical `<input type="range">` is native now: `writing-mode: vertical-lr; direction: rtl` gives
|
||||
bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free.
|
||||
- Keep the live `<output>` — on a fader it wants to sit at the top of the track, reading like a
|
||||
channel's gain display.
|
||||
- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside
|
||||
it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal
|
||||
one, so measure before choosing.
|
||||
- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are
|
||||
what make it read as equipment rather than decoration.
|
||||
|
||||
---
|
||||
|
||||
## The JSON API
|
||||
|
||||
Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes,
|
||||
pagination — and deliberately not implemented, because nothing calls it (decision 43).
|
||||
|
||||
When something does:
|
||||
|
||||
- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data
|
||||
functions the pages already use, so the domain rules cannot diverge between the surfaces.
|
||||
- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed
|
||||
field a test failure rather than a silent break in a client you cannot update.
|
||||
- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before.
|
||||
- The most likely first consumer is a native client (see above), and the endpoints it needs are
|
||||
login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one.
|
||||
|
||||
---
|
||||
|
||||
## Filters on the browse list
|
||||
|
||||
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
|
||||
|
||||
+5
-3
@@ -509,8 +509,10 @@ file.
|
||||
|
||||
## 8. API contract
|
||||
|
||||
Fixed before implementation, because the shape is the expensive thing to change once a client is
|
||||
installed somewhere you cannot update.
|
||||
**Not built.** Nothing consumes `/api` — the browser talks HTML to the page surface — so this
|
||||
section is a design, not a description of running code (decision 43). It stays here because the
|
||||
shape is the expensive thing to change once a client is installed somewhere you cannot update, and
|
||||
the first endpoint is one line over a data function that already exists.
|
||||
|
||||
**Conventions**
|
||||
|
||||
@@ -727,4 +729,4 @@ panel, so the admin surface comes first — before a single member can exist.
|
||||
this step.**
|
||||
5. **YouTube path** — yt-dlp metadata and download, slotted into a pipeline that already works.
|
||||
6. **Stats, profiles, avatars, palaute.**
|
||||
7. **API endpoints and golden tests**, once something wants them.
|
||||
7. **API endpoints and golden tests** — deferred until something wants them (decision 43).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Theme
|
||||
|
||||
Dark-only. Rock/metal club poster, not SaaS dashboard: near-black surfaces, warm bronze/amber
|
||||
accents, condensed uppercase display type, one-tone-lighter surfaces instead of borders everywhere.
|
||||
Restrained motion — 150 ms, one easing curve, no bounce. **There is no light theme and none is
|
||||
wanted.**
|
||||
|
||||
The tokens themselves live in [`static/style.css`](../static/style.css) as CSS custom properties, and
|
||||
that file is the source of truth. This page records the decisions behind them and the places the
|
||||
implementation deliberately differs from the theme handoff it came from.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Nothing outside `:root` invents a value.** No colour, spacing step, radius or duration appears in
|
||||
a rule unless it is declared as a token first. Six spacing steps (4–32 px), one radius (4 px, plus
|
||||
6 px for toasts and a pill), one duration, one curve.
|
||||
- **Headings step downward in brightness with level** — h1 lightest gold, h3 the primary bronze.
|
||||
- **Status colours are desaturated on purpose.** A pure red error would break the palette.
|
||||
- **`color-scheme: dark`** is set on `:root`, which is what keeps the native `<audio>` element,
|
||||
checkboxes, range inputs and scrollbars from rendering as white slabs.
|
||||
- **One focus treatment, everywhere:** a soft gold ring via `box-shadow` on `:focus-visible` only.
|
||||
Not optional — keyboard navigation is the only way through some admin tables.
|
||||
- **`prefers-reduced-motion`** drops the card lift and the panel slide, and keeps the fades.
|
||||
|
||||
## Type
|
||||
|
||||
Body is a system stack; no webfont for body text. Display is **Oswald**, vendored as a variable
|
||||
`.woff2` (latin subset, 21 KB) in `static/fonts/` — no CDN, matching the no-npm rule for HTMX.
|
||||
|
||||
The handoff asked for weight 900 in five places while loading only 400/600/700, so browsers were
|
||||
synthesising a fake bold that differed per platform. **Resolved as 700 being the top weight.** The
|
||||
variable font covers 400–700 and nothing asks for more.
|
||||
|
||||
## The unreviewed state
|
||||
|
||||
A song the viewer has not reviewed gets a red-brown border — it is the single most important state
|
||||
in the app, since it is how you see what still needs a review. It is **never carried by colour
|
||||
alone**: the card also shows an `arvostelematta` badge.
|
||||
|
||||
## Deviations from the handoff
|
||||
|
||||
| Handoff said | Here | Why |
|
||||
|---|---|---|
|
||||
| Tailwind theme config, utility classes | CSS custom properties, semantic classes | Decisions 4 and 8 — no Tailwind, no bundler, no npm |
|
||||
| Song cards are 16:9 tiles with cover artwork and a gradient scrim | Text cards, same borders, badges, hover glow and score badge | Songs have no artwork. There is no upload for one and nothing to derive it from |
|
||||
| Fixed bottom audio player bar | Player inline on the song page | Nothing plays across navigation, so a persistent bar would be an empty bar on every other page |
|
||||
| A styleguide page rendering every variant | Not built | The real pages cover every component; a second copy of them would drift |
|
||||
| Custom audio player skin | Native `<audio controls>` | Decision 14. It is keyboard-operable, screen-reader labelled and media-key aware for free, and replacing it later is one template partial — see [later.md](./later.md) |
|
||||
| Nav dropdown for the user block, hamburger with animated bars | User block is a plain row; mobile menu is `<details>` | No JS for either. The app has no dropdown-worthy menu yet: logout is one button |
|
||||
|
||||
Everything else — the palette, spacing, radii, shadows, motion, the 1450 px content width, the
|
||||
420 px auth column, badges, review cards, the admin section cards, bottom-right toasts — follows the
|
||||
handoff as written.
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -13,6 +14,9 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Set at build time with -ldflags "-X main.version=…". A local `go build` honestly says dev.
|
||||
var version = "dev"
|
||||
|
||||
type config struct {
|
||||
databaseURL string
|
||||
adminUser string
|
||||
@@ -67,6 +71,7 @@ type app struct {
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
||||
slog.Info("starting", "ctx", "startup", "version", version)
|
||||
cfg := loadConfig()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -96,7 +101,7 @@ func main() {
|
||||
if err := sweep(ctx, pool); err != nil {
|
||||
fatal("startup sweep", "error", err)
|
||||
}
|
||||
for _, dir := range []string{"audio", "tmp"} {
|
||||
for _, dir := range []string{"audio", "tmp", "avatars"} {
|
||||
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
||||
fatal("storage dir", "error", err, "dir", dir)
|
||||
}
|
||||
@@ -126,7 +131,8 @@ func (a *app) memberMux() *http.ServeMux {
|
||||
http.Error(w, "db down", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("ok"))
|
||||
// The version answers "what is actually running out there" without an SSH session.
|
||||
fmt.Fprintf(w, "ok %s\n", version)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /login", a.loginPage)
|
||||
@@ -141,6 +147,15 @@ func (a *app) memberMux() *http.ServeMux {
|
||||
mux.HandleFunc("POST /songs/{id}", a.requireMember(a.editSong))
|
||||
mux.HandleFunc("POST /songs/{id}/delete", a.requireMember(a.deleteSong))
|
||||
mux.HandleFunc("GET /audio/{id}", a.requireMember(a.audio))
|
||||
mux.HandleFunc("GET /avatars/{id}", a.avatar) // public: avatars are not secret
|
||||
|
||||
mux.HandleFunc("GET /stats", a.requireMember(a.statsPage))
|
||||
mux.HandleFunc("GET /profile", a.requireMember(a.profilePage))
|
||||
mux.HandleFunc("GET /profile/{id}", a.requireMember(a.profilePage))
|
||||
mux.HandleFunc("POST /profile", a.requireMember(a.editProfile))
|
||||
|
||||
mux.HandleFunc("GET /report", a.requireMember(a.reportPage))
|
||||
mux.HandleFunc("POST /report", a.requireMember(a.createReport))
|
||||
|
||||
mux.HandleFunc("POST /songs/{id}/review", a.requireMember(a.createReview))
|
||||
mux.HandleFunc("POST /reviews/{id}", a.requireMember(a.editReview))
|
||||
@@ -164,6 +179,10 @@ func (a *app) adminMux() *http.ServeMux {
|
||||
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
||||
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
||||
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
||||
mux.HandleFunc("POST /admin/songs/{id}/delete", a.adminDeleteSong)
|
||||
mux.HandleFunc("GET /admin/reports", a.adminReports)
|
||||
mux.HandleFunc("POST /admin/reports/{id}/resolve", a.resolveReport)
|
||||
mux.HandleFunc("GET /admin/audio/{id}", a.adminAudio)
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
@@ -176,6 +176,17 @@ func downloadYouTube(ctx context.Context, url, outTemplate string) (string, erro
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// toAvatarJPEG normalises any image ffmpeg understands into a 256px square JPEG. The re-encode is
|
||||
// the validation and the size cap in one — webp and avif included, which stdlib image cannot read.
|
||||
func toAvatarJPEG(ctx context.Context, in, out string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-y", "-i", in,
|
||||
"-vf", "scale=256:256:force_original_aspect_ratio=increase,crop=256:256",
|
||||
"-frames:v", "1", "-q:v", "3", out).Run()
|
||||
}
|
||||
|
||||
// convertToOpus is also the validation: if ffmpeg produced an Opus stream, the upload was audio.
|
||||
// No container sniffing, no magic-byte library. Returns the stderr tail on failure, which is worth
|
||||
// showing — "Invalid data found when processing input" beats "submission failed".
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const maxAvatarBytes = 5 << 20
|
||||
|
||||
type profileStats struct {
|
||||
SongsSubmitted int
|
||||
ReviewsWritten int
|
||||
AverageGiven *float64
|
||||
AverageReceived *float64
|
||||
}
|
||||
|
||||
type profileView struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string // only filled for your own profile
|
||||
Avatar *string
|
||||
CreatedAt time.Time
|
||||
Own bool
|
||||
Stats profileStats
|
||||
Songs []*songSummary
|
||||
Errors map[string]string
|
||||
}
|
||||
|
||||
func (p *profileView) Initials() string { m := member{Name: p.Name}; return m.Initials() }
|
||||
|
||||
func (a *app) avatarPath(userID int64) string {
|
||||
return filepath.Join(a.cfg.storageDir, "avatars", strconv.FormatInt(userID, 10)+".jpg")
|
||||
}
|
||||
|
||||
// Counts and history-wide averages only. A member's per-song opinions stay on the song pages —
|
||||
// per-song opinion is gated, whole-history aggregate is public.
|
||||
func (a *app) profile(ctx context.Context, viewerID, userID int64) (*profileView, error) {
|
||||
var p profileView
|
||||
err := a.pool.QueryRow(ctx, `
|
||||
select u.id, u.name, u.email, u.avatar, u.created_at,
|
||||
(select count(*) from songs s where s.submitted_by = u.id),
|
||||
(select count(*) from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score)::float from reviews r where r.reviewer_id = u.id),
|
||||
(select avg(r.score)::float from reviews r
|
||||
join songs s on s.id = r.song_id where s.submitted_by = u.id)
|
||||
from users u where u.id = $1`, userID).
|
||||
Scan(&p.ID, &p.Name, &p.Email, &p.Avatar, &p.CreatedAt,
|
||||
&p.Stats.SongsSubmitted, &p.Stats.ReviewsWritten,
|
||||
&p.Stats.AverageGiven, &p.Stats.AverageReceived)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Own = viewerID == userID
|
||||
if !p.Own {
|
||||
p.Email = ""
|
||||
}
|
||||
|
||||
// Their songs, with the viewer's own reveal rule applied to each average.
|
||||
rows, err := a.pool.Query(ctx, `select`+songColumns+`
|
||||
from songs s join users u on u.id = s.submitted_by
|
||||
where s.submitted_by = $2
|
||||
order by s.created_at desc`, viewerID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Songs, err = scanSongs(rows)
|
||||
return &p, err
|
||||
}
|
||||
|
||||
func (a *app) profilePage(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
id := me.ID
|
||||
if raw := r.PathValue("id"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id = parsed
|
||||
}
|
||||
p, err := a.profile(r.Context(), me.ID, id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("profile", "ctx", "auth", "error", err, "user", id)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "profile.html", page{Title: p.Name, Data: p})
|
||||
}
|
||||
|
||||
// Editing your own profile: name, email, password, avatar. Changing the password requires the
|
||||
// current one and drops your other sessions.
|
||||
func (a *app) editProfile(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
|
||||
if err := r.ParseMultipartForm(maxAvatarBytes); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||
a.flash(w, "Kuva on liian suuri. Enintään 5 MB.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
name := clean(r.FormValue("name"), 50)
|
||||
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
|
||||
if name == "" || !strings.Contains(email, "@") {
|
||||
a.flash(w, "Tarkista nimi ja sähköpostiosoite.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update users set name = $2, email = $3 where id = $1`, me.ID, name, email); isUnique(err) {
|
||||
a.flash(w, "Sähköpostiosoite on jo käytössä.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
} else if err != nil {
|
||||
slog.Error("edit profile", "ctx", "auth", "error", err, "user", me.ID)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword := r.FormValue("new_password"); newPassword != "" {
|
||||
if !a.changePassword(w, r, me.ID, r.FormValue("current_password"), newPassword) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if file, _, err := r.FormFile("avatar"); err == nil {
|
||||
defer file.Close()
|
||||
if err := a.saveAvatar(r, me.ID, file); err != nil {
|
||||
slog.Error("avatar", "ctx", "auth", "error", err, "user", me.ID)
|
||||
a.flash(w, "Kuvaa ei voitu käsitellä. Onko se varmasti kuva?")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a.flash(w, "Tiedot tallennettu.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *app) changePassword(w http.ResponseWriter, r *http.Request, userID int64, current, next string) bool {
|
||||
var hash string
|
||||
if err := a.pool.QueryRow(r.Context(),
|
||||
`select password_hash from users where id = $1`, userID).Scan(&hash); err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(current)) != nil {
|
||||
a.flash(w, "Nykyinen salasana ei täsmää.")
|
||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||
return false
|
||||
}
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update users set password_hash = $2 where id = $1`, userID, string(newHash)); err != nil {
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
// Every other session dies; this browser keeps its own.
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`delete from sessions where user_id = $1 and token <> $2`, userID, sessionToken(r)); err != nil {
|
||||
slog.Error("drop sessions", "ctx", "auth", "error", err, "user", userID)
|
||||
}
|
||||
slog.Info("password changed", "ctx", "auth", "user", userID)
|
||||
return true
|
||||
}
|
||||
|
||||
// The re-encode through ffmpeg is the validation, the same trick as audio: it handles webp and
|
||||
// avif (stdlib image does not), and it caps what ends up on disk.
|
||||
func (a *app) saveAvatar(r *http.Request, userID int64, file io.Reader) error {
|
||||
tmp := filepath.Join(a.cfg.storageDir, "tmp", "avatar-"+strconv.FormatInt(userID, 10))
|
||||
dst, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(dst, io.LimitReader(file, maxAvatarBytes))
|
||||
dst.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
|
||||
out := a.avatarPath(userID)
|
||||
if err := toAvatarJPEG(r.Context(), tmp, out); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.pool.Exec(r.Context(),
|
||||
`update users set avatar = $2 where id = $1`, userID, filepath.Base(out))
|
||||
return err
|
||||
}
|
||||
|
||||
// Avatars are public: they are not secret, and gating them buys nothing.
|
||||
func (a *app) avatar(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(a.avatarPath(id))
|
||||
if err != nil {
|
||||
// No upload: the template renders initials instead, and a client sees avatar_url null.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
http.ServeContent(w, r, "avatar.jpg", info.ModTime(), f)
|
||||
}
|
||||
@@ -16,7 +16,19 @@ var assetFS embed.FS
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
||||
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
||||
// Date without the clock: the minute a song was published is noise.
|
||||
"fiday": func(t time.Time) string { return t.Local().Format("2.1.2006") },
|
||||
"score": func(f *float64) string { return strconv.FormatFloat(*f, 'f', 1, 64) },
|
||||
"value": func(f float64) string { return strconv.FormatFloat(f, 'f', 1, 64) },
|
||||
// Lets one board partial be called with a title and a list, instead of two near-identical
|
||||
// partials per leaderboard.
|
||||
"dict": func(pairs ...any) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
m[pairs[i].(string)] = pairs[i+1]
|
||||
}
|
||||
return m
|
||||
},
|
||||
}
|
||||
|
||||
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
||||
@@ -41,12 +53,15 @@ func init() {
|
||||
|
||||
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
||||
type page struct {
|
||||
Title string
|
||||
Member *member
|
||||
Admin bool
|
||||
Flash string
|
||||
Path string
|
||||
Data any
|
||||
Title string
|
||||
Member *member
|
||||
Admin bool
|
||||
Flash string
|
||||
Path string
|
||||
Narrow bool // auth pages are a 420px column
|
||||
Queued int // songs still owed a review, shown in the nav
|
||||
Version string
|
||||
Data any
|
||||
}
|
||||
|
||||
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
||||
@@ -58,6 +73,16 @@ func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name st
|
||||
}
|
||||
p.Member = memberFrom(r.Context())
|
||||
p.Path = r.URL.Path
|
||||
p.Version = version
|
||||
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.
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxReportBody = 2000
|
||||
|
||||
type report struct {
|
||||
ID int64
|
||||
Body string
|
||||
Page string
|
||||
UserAgent string
|
||||
Reporter string
|
||||
ResolvedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (r *report) Open() bool { return r.ResolvedAt == nil }
|
||||
|
||||
type reportPage struct {
|
||||
From string
|
||||
Mine []*report
|
||||
}
|
||||
|
||||
// Free text and nothing else. No category, no priority, no severity — with ten users a sentence and
|
||||
// a page URL beat a taxonomy nobody fills in honestly.
|
||||
func (a *app) reportPage(w http.ResponseWriter, r *http.Request) {
|
||||
mine, err := a.myReports(r.Context(), memberFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
slog.Error("list reports", "ctx", "reports", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
from := r.URL.Query().Get("from")
|
||||
if !strings.HasPrefix(from, "/") {
|
||||
from = "/" // never redirect off-site on the strength of a query parameter
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "report.html",
|
||||
page{Title: "Palaute", Data: reportPage{From: from, Mine: mine}})
|
||||
}
|
||||
|
||||
// Seeing your own past reports is what stops the same bug arriving four times.
|
||||
func (a *app) myReports(ctx context.Context, userID int64) ([]*report, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select id, body, coalesce(page, ''), resolved_at, created_at
|
||||
from reports where user_id = $1 order by created_at desc`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*report
|
||||
for rows.Next() {
|
||||
var rep report
|
||||
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &rep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *app) createReport(w http.ResponseWriter, r *http.Request) {
|
||||
me := memberFrom(r.Context())
|
||||
body := clean(r.FormValue("body"), maxReportBody)
|
||||
from := r.FormValue("from")
|
||||
if !strings.HasPrefix(from, "/") {
|
||||
from = "/"
|
||||
}
|
||||
if body == "" {
|
||||
a.flash(w, "Kirjoita muutama sana siitä, mikä meni pieleen.")
|
||||
http.Redirect(w, r, "/report?from="+from, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// "Only on my phone" is the most common bug report and this answers it without asking.
|
||||
_, err := a.pool.Exec(r.Context(), `
|
||||
insert into reports (user_id, body, page, user_agent) values ($1, $2, nullif($3, ''), $4)`,
|
||||
me.ID, body, from, clean(r.Header.Get("User-Agent"), 300))
|
||||
if err != nil {
|
||||
slog.Error("create report", "ctx", "reports", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
slog.Info("report filed", "ctx", "reports", "user", me.ID)
|
||||
a.flash(w, "Kiitos! Palaute on perillä.")
|
||||
http.Redirect(w, r, from, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- admin ---
|
||||
|
||||
func (a *app) adminReports(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.pool.Query(r.Context(), `
|
||||
select rep.id, rep.body, coalesce(rep.page, ''), coalesce(rep.user_agent, ''),
|
||||
u.name, rep.resolved_at, rep.created_at
|
||||
from reports rep join users u on u.id = rep.user_id
|
||||
order by rep.resolved_at nulls first, rep.created_at desc`)
|
||||
if err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*report
|
||||
for rows.Next() {
|
||||
var rep report
|
||||
if err := rows.Scan(&rep.ID, &rep.Body, &rep.Page, &rep.UserAgent, &rep.Reporter,
|
||||
&rep.ResolvedAt, &rep.CreatedAt); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
out = append(out, &rep)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
a.render(w, r, http.StatusOK, "admin_reports.html",
|
||||
page{Title: "Palautteet", Admin: true, Data: out})
|
||||
}
|
||||
|
||||
// A nullable timestamp rather than a status enum: smaller, and it tells you *when*.
|
||||
func (a *app) resolveReport(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update reports set resolved_at = case when resolved_at is null then now() end where id = $1`,
|
||||
id); err != nil {
|
||||
adminError(w, "reports", err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/reports", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// The admin deletes a song unconditionally — a separate route from the submitter's, rather than one
|
||||
// route with a branch. The row and the file go together here too.
|
||||
func (a *app) adminDeleteSong(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tag, err := a.pool.Exec(r.Context(), `delete from songs where id = $1`, id)
|
||||
if err != nil {
|
||||
adminError(w, "songs", err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
removeFile(a.audioPath(id))
|
||||
slog.Info("song deleted by admin", "ctx", "songs", "song", id)
|
||||
a.flash(w, "Kappale poistettu.")
|
||||
}
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type adminSong struct {
|
||||
ID int64
|
||||
Title string
|
||||
Artist string
|
||||
Submitter string
|
||||
Reviews int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (a *app) adminSongs(ctx context.Context) ([]adminSong, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select s.id, s.title, s.artist, u.name,
|
||||
(select count(*) from reviews r where r.song_id = s.id)::int, s.created_at
|
||||
from songs s join users u on u.id = s.submitted_by
|
||||
order by s.created_at desc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []adminSong
|
||||
for rows.Next() {
|
||||
var s adminSong
|
||||
if err := rows.Scan(&s.ID, &s.Title, &s.Artist, &s.Submitter, &s.Reviews, &s.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Moderating a complaint means listening to the song, so the admin surface has its own audio route
|
||||
// rather than branching auth inside the member handler.
|
||||
func (a *app) adminAudio(w http.ResponseWriter, r *http.Request) {
|
||||
a.audio(w, r)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -269,12 +293,19 @@ func (a *app) deleteSong(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("/songs/%d", id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
os.Remove(a.audioPath(id))
|
||||
removeFile(a.audioPath(id))
|
||||
slog.Info("song deleted", "ctx", "songs", "song", id)
|
||||
a.flash(w, "Kappale poistettu.")
|
||||
http.Redirect(w, r, "/songs", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// A missing file is fine — the row is gone either way — but anything else is worth knowing about.
|
||||
func removeFile(path string) {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
slog.Error("remove file", "ctx", "songs", "error", err, "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
// --- audio ---
|
||||
|
||||
// Auth-gated, Range-capable, and not under /api because it serves bytes rather than JSON.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1">
|
||||
<linearGradient
|
||||
id="linearGradient1">
|
||||
<stop
|
||||
style="stop-color:#131313;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop1" />
|
||||
<stop
|
||||
style="stop-color:#1e1e1e;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop2" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
xlink:href="#linearGradient1"
|
||||
id="linearGradient2"
|
||||
x1="-126.14488"
|
||||
y1="1023.127"
|
||||
x2="-1038.4041"
|
||||
y2="94.281311"
|
||||
gradientUnits="userSpaceOnUse" />
|
||||
</defs>
|
||||
<g
|
||||
id="g1"
|
||||
transform="translate(1112.6069,-41.466324)">
|
||||
<rect
|
||||
style="font-variation-settings:'wght' 400;opacity:1;fill:url(#linearGradient2);stroke:none;stroke-width:1.50385"
|
||||
id="rect1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
x="-1112.6069"
|
||||
y="41.466324" />
|
||||
<path
|
||||
style="fill:#a96832;fill-opacity:1"
|
||||
d="m -256.32286,859.80613 c -0.5587,-1.0439 -5.998,-14.34542 -12.0874,-29.55893 -6.0894,-15.21351 -12.6981,-31.36028 -14.6861,-35.88171 -1.9879,-4.52143 -6.2446,-15.32745 -9.4591,-24.01338 -9.8775,-26.68952 -17.0559,-44.11196 -19.469,-47.25279 -1.2676,-1.65 -6.3824,-7.275 -11.3661,-12.5 -4.9837,-5.225 -9.2794,-10.4 -9.546,-11.5 -0.2667,-1.1 -1.0926,-9.875 -1.8354,-19.5 l -1.3507,-17.5 -8.7081,-21 c -19.3876,-46.75383 -39.6764,-97.57184 -39.2382,-98.28098 0.2562,-0.41453 9.0751,-6.5959 19.5975,-13.73636 10.5224,-7.14046 25.298,-17.36069 32.8348,-22.71161 7.5367,-5.35093 16.5609,-11.49239 20.0538,-13.64769 l 6.3507,-3.91873 -0.138,-14.35231 c -0.088,-9.11047 0.4034,-17.17033 1.3445,-22.06808 1.2363,-6.43377 1.4019,-16.5691 0.9969,-61 -0.2672,-29.30633 -0.7305,-55.53424 -1.0295,-58.28424 -0.6352,-5.84093 9.315,3.05716 -80.2267,-71.74374 l -20.3514,-17.00104 -159.3986,-0.43563 c -93.9669,-0.2568 -159.3986,-0.0666 -159.3986,0.46325 0,0.79387 14.1728,39.5009 32.3012,88.21716 l 6.14,16.5 -0.4706,20 c -0.5906,25.10141 -0.61,106.08069 -0.035,147 0.2396,17.05 -0.058,42.25 -0.6606,56 -0.6029,13.75 -1.4093,49.4125 -1.7922,79.25 l -0.696,54.25 h 34.4369 34.4369 l 17.6574,-12.25 c 9.7116,-6.7375 23.2724,-16.075 30.1351,-20.75 l 12.4776,-8.5 0.045,-28 0.045,-28 23.7399,-10.46084 c 13.0569,-5.75347 28.8714,-12.84696 35.1432,-15.76332 6.2719,-2.91637 11.4981,-5.18399 11.6139,-5.03916 0.1159,0.14483 1.3683,3.86332 2.7832,8.26332 1.415,4.4 8.1816,25.1 15.0368,46 11.4894,35.02826 12.867,38.58653 17.6148,45.5 2.8328,4.125 7.6915,11.24468 10.797,15.8215 l 5.6464,8.3215 0.8322,11.6785 c 2.1376,29.99811 3.0902,34.82716 12.5368,63.55422 l 8.8379,26.87572 5.9488,2.32255 c 3.2719,1.27739 12.0239,4.62345 19.4489,7.43567 7.425,2.81223 18.4886,7.11051 24.5858,9.55174 6.0972,2.44123 11.7197,4.4386 12.4945,4.4386 2.6086,0 23.1976,8.18948 27.9673,11.12431 2.5995,1.59952 12.5398,5.78582 22.0894,9.30289 9.5496,3.51707 23.888,8.84253 31.863,11.83435 14.6625,5.50065 16.6188,5.6317 14.1107,0.94526 z m -303.1107,-418.51779 v -57.34908 l -23.4418,-17.91997 c -12.893,-9.85598 -23.468,-18.20969 -23.5,-18.56379 -0.032,-0.3541 34.315,-0.53781 76.3268,-0.40824 l 76.3849,0.23557 7.3651,5.99943 c 4.0507,3.29968 10.74,8.39168 14.865,11.31555 4.125,2.92387 9.2498,6.8043 11.3884,8.62319 l 3.8884,3.30707 -0.1932,29.78562 c -0.1063,16.3821 -0.4246,30.68563 -0.7073,31.78563 -0.2828,1.1 -4.6655,5.22906 -9.7393,9.17568 -5.0739,3.94663 -12.9359,10.38223 -17.4712,14.30134 l -8.246,7.12566 -37.2099,10.49833 c -20.4655,5.77409 -43.2849,12.21526 -50.7099,14.31371 -7.425,2.09845 -14.7375,4.10967 -16.25,4.46938 l -2.75,0.654 z m 45.446,417.1983 c 0.2839,-0.28388 1.4247,-7.59187 2.5351,-16.23998 1.8573,-14.46552 2.0189,-21.36257 2.0189,-86.18559 0,-40.49991 -0.3784,-70.46175 -0.89,-70.46175 -0.4894,0 -6.9019,4.40065 -14.25,9.77923 -11.1102,8.13247 -58.8721,42.62513 -78.1649,56.44912 l -5.2033,3.72836 -111.246,0.27164 c -61.1854,0.14941 -111.246,0.21437 -111.246,0.14437 10e-5,-0.07 4.1627,-3.4641 9.2502,-7.54242 11.3961,-9.13555 33.5431,-26.93368 35.3917,-28.44206 1.1443,-0.93375 1.3221,-39.12151 1.1104,-238.5 l -0.2521,-237.38824 -79.75,-0.25599 c -65.2629,-0.20948 -79.75,-0.0177 -79.75,1.05568 0,0.72141 1.6361,5.33661 3.6358,10.25598 1.9997,4.91938 6.4668,16.14433 9.9269,24.94433 3.4601,8.8 10.0502,25.225 14.6447,36.5 l 8.3537,20.5 -0.1377,207.42507 -0.1377,207.42508 -18.8328,19.343 -18.8327,19.34299 0.206,33.73193 c 0.1133,18.55256 0.1987,34.00382 0.1899,34.33613 -0.02,0.73234 430.6972,0.51582 431.4299,-0.21688 z"
|
||||
id="path1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
@@ -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)
|
||||
})
|
||||
})()
|
||||
+911
-162
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// A song needs this many reviews to qualify for any ranking: with ten members it means a third of
|
||||
// the club has weighed in, which is a real threshold rather than a formality.
|
||||
const minReviews = 3
|
||||
|
||||
type songStat struct {
|
||||
ID int64
|
||||
Title string
|
||||
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 {
|
||||
ID int64
|
||||
Name string
|
||||
Value float64
|
||||
Count int
|
||||
Avatar *string
|
||||
}
|
||||
|
||||
func (u *userStat) Initials() string { m := member{Name: u.Name}; return m.Initials() }
|
||||
|
||||
type stats struct {
|
||||
MinReviews int
|
||||
|
||||
TopSongs []songStat
|
||||
BottomSongs []songStat
|
||||
MostDivisive []songStat
|
||||
MostUnified []songStat
|
||||
MostReviewed []songStat
|
||||
|
||||
Harshest []userStat
|
||||
MostGenerous []userStat
|
||||
MostActive []userStat
|
||||
MostProlific []userStat
|
||||
}
|
||||
|
||||
// Every leaderboard is ordered and limited in SQL, and every one carries a deterministic tie-break:
|
||||
// ties are common in a ten-person club, and without one Postgres may return a different ten each
|
||||
// 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,
|
||||
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
|
||||
order by value `+direction+`, reviews desc, s.id asc
|
||||
limit 10`, minReviews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []songStat
|
||||
for rows.Next() {
|
||||
var s songStat
|
||||
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)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Reviewer boards need a minimum too, or one enthusiastic 100 makes someone the most generous
|
||||
// member in the club forever.
|
||||
func (a *app) reviewerLeaderboard(ctx context.Context, valueExpr, direction string) ([]userStat, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select u.id, u.name, u.avatar, `+valueExpr+`::float as value, count(r.id)::int as n
|
||||
from users u join reviews r on r.reviewer_id = u.id
|
||||
group by u.id
|
||||
having count(r.id) >= $1
|
||||
order by value `+direction+`, n desc, u.id asc
|
||||
limit 10`, minReviews)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []userStat
|
||||
for rows.Next() {
|
||||
var u userStat
|
||||
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *app) mostProlific(ctx context.Context) ([]userStat, error) {
|
||||
rows, err := a.pool.Query(ctx, `
|
||||
select u.id, u.name, u.avatar, count(s.id)::float, count(s.id)::int
|
||||
from users u join songs s on s.submitted_by = u.id
|
||||
group by u.id
|
||||
order by count(s.id) desc, u.id asc
|
||||
limit 10`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []userStat
|
||||
for rows.Next() {
|
||||
var u userStat
|
||||
if err := rows.Scan(&u.ID, &u.Name, &u.Avatar, &u.Value, &u.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// The reveal rule does not apply here: leaderboards are always public. That is the whole point of
|
||||
// /stats being a page you walk to deliberately.
|
||||
func (a *app) statsPage(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
s := stats{MinReviews: minReviews}
|
||||
|
||||
var err error
|
||||
for _, load := range []func() error{
|
||||
func() (err error) { s.TopSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
||||
func() (err error) { s.BottomSongs, err = a.songLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
||||
func() (err error) {
|
||||
s.MostDivisive, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "desc")
|
||||
return
|
||||
},
|
||||
func() (err error) { s.MostUnified, err = a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc"); return },
|
||||
func() (err error) { s.MostReviewed, err = a.songLeaderboard(ctx, "count(r.id)", "desc"); return },
|
||||
func() (err error) { s.Harshest, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "asc"); return },
|
||||
func() (err error) { s.MostGenerous, err = a.reviewerLeaderboard(ctx, "avg(r.score)", "desc"); return },
|
||||
func() (err error) { s.MostActive, err = a.reviewerLeaderboard(ctx, "count(r.id)", "desc"); return },
|
||||
func() (err error) { s.MostProlific, err = a.mostProlific(ctx); return },
|
||||
} {
|
||||
if err = load(); err != nil {
|
||||
slog.Error("stats", "ctx", "songs", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a.render(w, r, http.StatusOK, "stats.html", page{Title: "Tilastot", Data: s})
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A song qualifies only at minReviews, and the order is decided in SQL — with a tie-break, so the
|
||||
// same ten come back in the same order every time.
|
||||
func TestLeaderboardThresholdAndOrder(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
submitter := a.seedMember(t, "[email protected]")
|
||||
var reviewers []int64
|
||||
for _, e := range []string{"[email protected]", "[email protected]", "[email protected]"} {
|
||||
reviewers = append(reviewers, a.seedMember(t, e))
|
||||
}
|
||||
|
||||
loved := a.seedSong(t, submitter, "Rakastettu")
|
||||
hated := a.seedSong(t, submitter, "Vihattu")
|
||||
ignored := a.seedSong(t, submitter, "Kahdesti arvosteltu")
|
||||
|
||||
for _, r := range reviewers {
|
||||
a.seedReview(t, loved, r, 90)
|
||||
a.seedReview(t, hated, r, 20)
|
||||
}
|
||||
// One short of the threshold.
|
||||
a.seedReview(t, ignored, reviewers[0], 100)
|
||||
a.seedReview(t, ignored, reviewers[1], 100)
|
||||
|
||||
top, err := a.songLeaderboard(ctx, "avg(r.score)", "desc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(top) != 2 {
|
||||
t.Fatalf("top has %d entries, want 2 — the third song is below %d reviews", len(top), minReviews)
|
||||
}
|
||||
if top[0].ID != loved || top[1].ID != hated {
|
||||
t.Fatalf("top order is %d, %d — want %d first", top[0].ID, top[1].ID, loved)
|
||||
}
|
||||
if top[0].Value != 90 || top[0].ReviewCount != 3 {
|
||||
t.Fatalf("top entry = %v with %d reviews, want 90 and 3", top[0].Value, top[0].ReviewCount)
|
||||
}
|
||||
|
||||
bottom, err := a.songLeaderboard(ctx, "avg(r.score)", "asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bottom[0].ID != hated {
|
||||
t.Fatalf("bottom starts with %d, want %d", bottom[0].ID, hated)
|
||||
}
|
||||
|
||||
// Identical scores everywhere means stddev 0, so unified beats divisive on the same data.
|
||||
unified, err := a.songLeaderboard(ctx, "stddev_pop(r.score)", "asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unified[0].Value != 0 {
|
||||
t.Fatalf("most unified has stddev %v, want 0", unified[0].Value)
|
||||
}
|
||||
|
||||
// Ties are the common case in a ten-person club: the same query must return the same order.
|
||||
first, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
|
||||
second, _ := a.songLeaderboard(ctx, "count(r.id)", "desc")
|
||||
for i := range first {
|
||||
if first[i].ID != second[i].ID {
|
||||
t.Fatal("a tied leaderboard reshuffles between calls — the tie-break is missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles are counts and history-wide averages. They never carry per-song opinions.
|
||||
func TestProfileStats(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
aino := a.seedMember(t, "[email protected]")
|
||||
bertta := a.seedMember(t, "[email protected]")
|
||||
|
||||
song := a.seedSong(t, aino, "Testikappale")
|
||||
a.seedReview(t, song, bertta, 80)
|
||||
other := a.seedSong(t, bertta, "Berttan kappale")
|
||||
a.seedReview(t, other, aino, 40)
|
||||
|
||||
p, err := a.profile(ctx, bertta, aino)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Stats.SongsSubmitted != 1 || p.Stats.ReviewsWritten != 1 {
|
||||
t.Fatalf("counts = %d songs, %d reviews; want 1 and 1",
|
||||
p.Stats.SongsSubmitted, p.Stats.ReviewsWritten)
|
||||
}
|
||||
if p.Stats.AverageGiven == nil || *p.Stats.AverageGiven != 40 {
|
||||
t.Fatalf("average given = %v, want 40", p.Stats.AverageGiven)
|
||||
}
|
||||
if p.Stats.AverageReceived == nil || *p.Stats.AverageReceived != 80 {
|
||||
t.Fatalf("average received = %v, want 80", p.Stats.AverageReceived)
|
||||
}
|
||||
// Viewing someone else's profile does not expose their email.
|
||||
if p.Email != "" {
|
||||
t.Fatalf("another member's email leaked: %q", p.Email)
|
||||
}
|
||||
// Their songs still obey the viewer's own reveal rule. Bertta reviewed this one, so she sees
|
||||
// its average here.
|
||||
if len(p.Songs) != 1 {
|
||||
t.Fatalf("profile lists %d songs, want 1", len(p.Songs))
|
||||
}
|
||||
if p.Songs[0].Average == nil {
|
||||
t.Fatal("a reviewer cannot see the average of a song they reviewed")
|
||||
}
|
||||
|
||||
// A third member who has reviewed nothing must not learn it from the profile page.
|
||||
cecilia := a.seedMember(t, "[email protected]")
|
||||
p, err = a.profile(ctx, cecilia, aino)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Songs[0].Average != nil {
|
||||
t.Fatal("profile leaked a song average to someone who has not reviewed it")
|
||||
}
|
||||
}
|
||||
+50
-16
@@ -1,49 +1,53 @@
|
||||
{{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>
|
||||
<h2>Kutsut</h2>
|
||||
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
|
||||
<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>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
<a href="{{.Link}}" class="invite">{{.Link}}</a>
|
||||
{{else}}
|
||||
<code class="muted">{{.Code}}</code>
|
||||
{{end}}
|
||||
<a href="{{.Link}}">{{.Link}}</a>
|
||||
</td>
|
||||
<td>{{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">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>
|
||||
|
||||
<section>
|
||||
<h2>Jäsenet</h2>
|
||||
<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="tag">estetty</span>{{end}}</td>
|
||||
<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">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
||||
<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">Vaihda salasana</button>
|
||||
<button type="submit" class="ghost">Vaihda salasana</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -52,5 +56,35 @@
|
||||
{{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="/admin/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>
|
||||
|
||||
{{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}}
|
||||
+62
-13
@@ -4,29 +4,78 @@
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a>
|
||||
<nav>
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
|
||||
|
||||
{{if .Admin}}
|
||||
<a href="/admin">Ylläpito</a>
|
||||
<nav class="navlinks"><a href="/admin" aria-current="page">Ylläpito</a></nav>
|
||||
<span></span>
|
||||
{{else if .Member}}
|
||||
<a href="/">Jono</a>
|
||||
<a href="/songs">Kappaleet</a>
|
||||
<a href="/submit">Lähetä</a>
|
||||
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
|
||||
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
|
||||
<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>
|
||||
</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>
|
||||
<a href="/profile">Oma profiili</a>
|
||||
<form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="/login">Kirjaudu</a>
|
||||
<span></span>
|
||||
<nav class="navlinks"><a href="/login">Kirjaudu</a></nav>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{with .Flash}}<p class="flash">{{.}}</p>{{end}}
|
||||
<main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
|
||||
|
||||
<main>{{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}}">Ilmoita ongelmasta</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>
|
||||
|
||||
@@ -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,30 +1,38 @@
|
||||
{{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 "songrow"}}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/songs/{{.ID}}">{{.Title}}</a>
|
||||
<span class="muted"> — {{.Artist}}</span>
|
||||
{{if .Own}}<span class="tag">oma</span>{{else if .Reviewed}}<span class="tag done">arvosteltu</span>{{end}}
|
||||
</td>
|
||||
<td class="nowrap">{{.GenreLabel}}</td>
|
||||
<td class="nowrap">{{.Length}}</td>
|
||||
<td class="nowrap">
|
||||
{{if .Average}}<strong>{{score .Average}}</strong> <span class="muted">({{.ReviewCount}})</span>
|
||||
{{else if .ReviewCount}}<span class="muted">{{.ReviewCount}} arvostelua</span>
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{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 "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="Pisteet paljastuvat kun arvostelet"></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>
|
||||
</label>
|
||||
</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}}
|
||||
+10
-9
@@ -4,15 +4,16 @@
|
||||
{{if .Data.Items}}
|
||||
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
|
||||
olet kirjoittanut oman arvostelusi.</p>
|
||||
<table>
|
||||
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Arvostelut</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Items}}{{template "songrow" .}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}}
|
||||
<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">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}}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content"}}
|
||||
<h1>Palaute</h1>
|
||||
<p class="muted">Kerro mikä on rikki tai ärsyttää. Ei kategorioita eikä prioriteetteja — 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. soitin ei toimi puhelimella."></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}}
|
||||
+101
-55
@@ -1,16 +1,18 @@
|
||||
{{define "content"}}
|
||||
{{$s := .Data}}
|
||||
<h1>{{$s.Title}}</h1>
|
||||
<p class="byline">
|
||||
{{$s.Artist}} · {{$s.GenreLabel}} · {{$s.Length}} ·
|
||||
lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}}
|
||||
{{if $s.Own}}<span class="tag">oma kappale</span>{{end}}
|
||||
</p>
|
||||
<p class="by">{{$s.Artist}}</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}}
|
||||
<!-- 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">
|
||||
@@ -31,7 +33,7 @@
|
||||
</form>
|
||||
<form method="post" action="/songs/{{$s.ID}}/delete"
|
||||
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
|
||||
<button type="submit" class="danger">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>
|
||||
@@ -39,67 +41,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">
|
||||
<header>
|
||||
<span class="avatar">{{.Initials}}</span>
|
||||
<strong>{{.Reviewer}}</strong>
|
||||
<span class="score">{{.Score}}</span>
|
||||
<span class="muted small">{{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}}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<h1>Kappaleet</h1>
|
||||
|
||||
{{if .Data.Items}}
|
||||
<table>
|
||||
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Pisteet</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Items}}{{template "songrow" .}}{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}}
|
||||
<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>
|
||||
|
||||
@@ -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 — täällä pisteitä ei piiloteta.</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}}
|
||||
@@ -1,5 +1,11 @@
|
||||
{{define "submission-status"}}
|
||||
<div class="status" {{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>
|
||||
{{if .Failed}}
|
||||
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user