Apply the theme: tokens, Oswald, song cards, toasts

The theme handoff encoded as CSS custom properties rather than a Tailwind
config, since there is no Tailwind here. Palette, spacing, radii, shadows and
motion follow it as written; docs/theme.md lists what differs and why.

- Oswald vendored as a 21KB variable woff2, latin subset, no CDN. Its phantom
  weight 900 resolved to 700 — loading a weight you don't have is what made
  the brand render differently per platform
- color-scheme: dark makes the native audio element fit the palette, which
  was the handoff's complaint about it
- Songs are text cards rather than artwork tiles, because there is no
  artwork. The unreviewed state keeps its red-brown border and gains a badge,
  so it is never carried by colour alone
- Nav is the three-column grid; the mobile menu is <details>, no JS
- Flash messages became bottom-right toasts

Favicon carried over from the Nuxt project.
This commit is contained in:
Esa Kataja
2026-07-31 22:16:29 +03:00
parent 91e136055c
commit f33f4fa4d6
16 changed files with 724 additions and 232 deletions
+1
View File
@@ -16,6 +16,7 @@ Invite-only, no public registration. Built for about ten friends.
| [CONTEXT.md](CONTEXT.md) | The glossary — every domain term, in English and Finnish | | [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/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/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 | | [docs/later.md](docs/later.md) | Deliberately not in v1, with the reasoning kept |
## Branches ## Branches
+8 -8
View File
@@ -160,7 +160,7 @@ type authForm struct {
} }
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) { 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) { 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) { if a.logins.locked(email) {
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen." 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 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 // One message for both cases: a distinct "no such account" tells anyone who asks which
// addresses are members. // addresses are members.
form.Errors["form"] = "Sähköposti tai salasana ei täsmää." 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 return
} }
if banned { if banned {
form.Errors["form"] = "Tunnus on estetty." 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 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) { func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "register.html", 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 // 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." form.Errors["code"] = "Kutsukoodi on pakollinen."
} }
if len(form.Errors) > 0 { 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 return
} }
@@ -273,7 +273,7 @@ func (a *app) register(w http.ResponseWriter, r *http.Request) {
form.Code).Scan(&inviteID) form.Code).Scan(&inviteID)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa." 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 return
} else if err != nil { } else if err != nil {
slog.Error("burn invite", "ctx", "invites", "error", err) 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) { if isUnique(err) {
// Rolls back, so the invite is still valid. // Rolls back, so the invite is still valid.
form.Errors["email"] = "Sähköpostiosoite on jo käytössä." 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 return
} else if err != nil { } else if err != nil {
slog.Error("create user", "ctx", "auth", "error", err) slog.Error("create user", "ctx", "auth", "error", err)
+7
View File
@@ -162,6 +162,13 @@ says so.
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge 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 at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
`dev`. `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, 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 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 lockout, cleared by a correct password. The floor was rejected because typing an 8-character
+21
View File
@@ -130,6 +130,27 @@ 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.
---
## Filters on the browse list ## Filters on the browse list
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones `/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
+53
View File
@@ -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 (432 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 400700 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.
+1
View File
@@ -46,6 +46,7 @@ type page struct {
Admin bool Admin bool
Flash string Flash string
Path string Path string
Narrow bool // auth pages are a 420px column
Data any Data any
} }
+50
View File
@@ -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.
+483 -163
View File
@@ -1,21 +1,80 @@
/* Theme tokens first — the dark rock/metal look lives here and nowhere else. */ /* Levyraati — dark-only rock club poster. Tokens first; nothing below invents a colour, a spacing
step, a radius or a duration that isn't declared here. */
/* Oswald variable, latin subset, vendored — no CDN, and no webfont for body text. 400700 only:
asking for 900 makes the browser synthesise a fake bold that differs per platform. */
@font-face {
font-family: "Oswald";
src: url("/static/fonts/oswald.woff2") format("woff2");
font-weight: 400 700;
font-display: swap;
font-style: normal;
}
:root { :root {
--bg: #121212; /* Surfaces */
--surface: #1c1c1e; --bg: #111111;
--surface-2: #26262a; --surface: #1a1a1a;
--border: #35353a; --surface-raised: #222222;
--text: #ece9e6; --surface-header: #1e1e1e;
--muted: #9a948d; --bar: #0d0d0d;
--accent: #ff5722; --hairline: #2a2a2a;
--accent-2: #c62828; --divider: #333333;
--error: #ef5350; --input-border: #444444;
/* Text */
--text: #e0e0e0;
--muted: #888888;
--text-strong: #f0f0f0;
/* Accent — bronze/amber, not neon */
--primary: #a0693a;
--primary-hover: #b57848;
--primary-wash: rgba(160, 105, 58, 0.2);
--primary-inverse: #111111;
--gold-1: #c4a96a;
--gold-2: #b89558;
/* Status, deliberately desaturated */
--error: #e05959;
--error-bg: rgba(224, 89, 89, 0.08);
--success: #7db07d;
--success-bg: rgba(125, 176, 125, 0.08);
--pending: #8f4a44;
--pending-bg: #2e2020;
--unreviewed: #7a3a36;
--unreviewed-hover: #a04540;
/* Scale — 4px base, six steps, nothing in between */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--radius: 4px; --radius: 4px;
/* ponytail: system stack until an Oswald woff2 is vendored into /static. */ --radius-toast: 6px;
--font-head: "Oswald", "Fira Sans Condensed", "Arial Narrow", system-ui, sans-serif; --radius-pill: 99px;
--duration-fast: 150ms;
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.03);
--shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 24px rgba(180, 120, 72, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
--shadow-focus: 0 0 0 3px rgba(196, 169, 106, 0.35);
/* Native controls (audio, checkbox, range, scrollbars) follow this — it is what stops the
<audio> element rendering as a white slab against the dark page. */
color-scheme: dark;
--content: 1450px;
--font-display: "Oswald", Impact, system-ui, sans-serif;
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif; --font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
} }
* { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
@@ -25,209 +84,470 @@ body {
line-height: 1.5; line-height: 1.5;
} }
h1, h2, h3 { /* One focus treatment everywhere, on :focus-visible only. Keyboard navigation is the only way
font-family: var(--font-head); through some admin tables — this ring is not optional. */
text-transform: uppercase; a:focus-visible, button:focus-visible, input:focus-visible,
letter-spacing: 0.04em; textarea:focus-visible, select:focus-visible, summary:focus-visible,
margin: 0 0 0.6rem; [role="button"]:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
border-radius: var(--radius);
} }
h1 { color: var(--accent); font-size: 1.9rem; } h1, h2, h3 {
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } font-family: var(--font-display);
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 var(--space-3);
}
a { color: var(--accent); } /* Headings step downward in brightness with level. */
h1 { font-size: 2rem; color: var(--gold-1); }
h2 { font-size: 1.5rem; color: var(--gold-2); }
h3 { font-size: 1.2rem; color: var(--primary); }
h4 { font-family: var(--font-body); font-weight: 800; letter-spacing: -0.02em; }
header { a { color: var(--primary); text-decoration-color: var(--primary-wash); }
display: flex; a:hover { color: var(--primary-hover); }
/* --- top bar --- */
header.topbar {
background: var(--bar);
border-bottom: 2px solid var(--divider);
padding: var(--space-3) var(--space-5);
position: relative;
}
/* Three columns, not space-between: this is what keeps the links optically centred whatever the
brand and user block weigh. */
.topbar-inner {
max-width: var(--content);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
justify-content: space-between; gap: var(--space-4);
gap: 1rem;
padding: 0.8rem 1.2rem;
background: var(--surface);
border-bottom: 2px solid var(--accent-2);
} }
.brand { .brand {
font-family: var(--font-head); font-family: var(--font-display);
font-size: 1.3rem; font-size: 1.4rem;
font-weight: 700;
letter-spacing: -0.03em;
text-transform: uppercase; text-transform: uppercase;
text-decoration: none;
color: var(--text); color: var(--text);
text-decoration: none;
} }
nav { display: flex; align-items: center; gap: 1rem; } .navlinks { display: flex; gap: var(--space-2); justify-content: center; }
nav a { text-decoration: none; }
main { max-width: 52rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; } .navlinks a {
section { margin-bottom: 2.5rem; } padding: 0.4rem var(--space-3);
.muted { color: var(--muted); }
.error { color: var(--error); display: block; font-size: 0.9rem; }
.tag {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--accent-2);
padding: 0.1rem 0.4rem;
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text);
text-decoration: none;
font-size: 0.9rem;
transition: background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
} }
.flash { /* Hover and current route look the same, on purpose. */
max-width: 52rem; .navlinks a:hover, .navlinks a[aria-current="page"] {
margin: 1rem auto 0; background: var(--surface-raised);
padding: 0.7rem 1rem; color: var(--primary);
background: var(--surface-2);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
} }
.userblock {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
line-height: 1.2;
}
.userblock .lines { text-align: right; display: flex; flex-direction: column; }
.userblock .name { font-size: 0.85rem; font-weight: 600; }
.userblock .email { font-size: 0.7rem; color: var(--muted); }
.avatar { .avatar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2rem; width: 2.25rem;
height: 2rem; height: 2.25rem;
flex: none;
border-radius: 50%; border-radius: 50%;
background: var(--accent-2); background: var(--surface-raised);
font-family: var(--font-head); border: 1px solid var(--input-border);
font-size: 0.85rem; color: var(--gold-1);
font-family: var(--font-display);
font-size: 0.9rem;
font-weight: 600;
} }
form.stack { display: flex; flex-direction: column; gap: 0.9rem; max-width: 24rem; } .avatar.small { width: 1.8rem; height: 1.8rem; font-size: 0.75rem; }
form.stack label { display: flex; flex-direction: column; gap: 0.25rem; }
form.stack label.row { flex-direction: row; align-items: center; gap: 0.5rem; }
input { /* --- layout --- */
background: var(--surface-2);
main { max-width: var(--content); margin: 0 auto; padding: var(--space-6) var(--space-5); }
main.narrow { max-width: 420px; padding-top: 8vh; }
section { margin-bottom: var(--space-6); }
footer.sitefooter {
text-align: center;
color: var(--muted);
font-size: 0.8rem;
padding: var(--space-6) var(--space-4) var(--space-4);
}
.muted { color: var(--muted); }
.small { font-size: 0.8rem; }
.nowrap { white-space: nowrap; }
.error { color: var(--error); display: block; font-size: 0.85rem; }
.empty { font-family: var(--font-display); color: var(--muted); padding: var(--space-6) 0; }
/* --- badges --- */
.badge {
display: inline-block;
border-radius: var(--radius-pill);
padding: 0.15rem var(--space-2);
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge.genre { background: var(--surface-raised); color: var(--primary); border: 1px solid var(--input-border); }
.badge.admin { background: #3a1a1a; color: var(--gold-2); }
.badge.reviewed { background: var(--success-bg); color: var(--success); border: 1px solid var(--success); }
.badge.pending { background: var(--pending-bg); color: var(--pending); }
/* --- song grid --- */
.songgrid { display: grid; grid-template-columns: 1fr; gap: var(--space-4); }
@media (min-width: 640px) { .songgrid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .songgrid { grid-template-columns: repeat(3, 1fr); } }
.songcard {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-1);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: var(--space-4);
padding-right: var(--space-6);
text-decoration: none;
color: var(--text); color: var(--text);
border: 1px solid var(--border); box-shadow: var(--shadow-card);
transition: transform var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.songcard:hover {
transform: translateY(-2px);
border-color: var(--primary);
box-shadow: var(--shadow-card-hover);
}
/* The single most important state in the app: what still needs a review. Carried by the border
AND the pending badge — never by colour alone. */
.songcard.unreviewed { border-color: var(--unreviewed); }
.songcard.unreviewed:hover { border-color: var(--unreviewed-hover); }
.songcard .title { font-size: 1.1rem; font-weight: 700; color: var(--text-strong); }
.songcard .artist { font-size: 0.9rem; color: #cccccc; }
.songcard .meta { font-size: 0.75rem; color: var(--muted); display: flex; gap: var(--space-2);
align-items: center; flex-wrap: wrap; margin-top: var(--space-2); }
.songcard .scorebadge {
position: absolute;
top: var(--space-3);
right: var(--space-3);
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 700;
color: var(--gold-1);
background: rgba(0, 0, 0, 0.5);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.5rem 0.6rem; padding: 0 var(--space-2);
}
/* --- song page and reviews --- */
.songmeta { color: var(--muted); display: flex; flex-wrap: wrap; gap: var(--space-2);
align-items: center; margin: calc(-1 * var(--space-2)) 0 var(--space-4); }
.intro { white-space: pre-wrap; background: var(--surface); padding: var(--space-4);
border-left: 3px solid var(--hairline); border-radius: 0 var(--radius) var(--radius) 0; }
.average { font-family: var(--font-display); font-size: 1.2rem; color: var(--gold-1); }
.review {
padding: var(--space-4);
border-left: 3px solid var(--input-border);
background: var(--surface);
border-radius: 0 var(--radius) var(--radius) 0;
margin-bottom: var(--space-2);
}
.review.own { border-left-color: var(--primary); }
.review header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-2); }
.review .who { font-weight: 700; color: var(--primary); }
.review .score { font-family: var(--font-display); font-size: 1.8rem; font-weight: 700; color: var(--gold-1); }
.review p { white-space: pre-wrap; line-height: 1.6; margin: 0; }
.review .meta { font-size: 0.8rem; color: var(--muted); }
.player { width: 100%; margin: var(--space-3) 0 var(--space-5); }
/* --- forms --- */
form.stack { display: flex; flex-direction: column; gap: var(--space-4); }
form.stack label { display: flex; flex-direction: column; gap: var(--space-1); }
form.stack label.row { flex-direction: row; align-items: center; gap: var(--space-2); }
/* Buttons size to their label rather than stretching across the column. */
form.stack > button, form.stack > .actions { align-self: flex-start; }
input, select, textarea {
background: var(--surface);
color: var(--text);
border: 1px solid var(--input-border);
border-radius: var(--radius);
padding: var(--space-2) var(--space-3);
font: inherit; font: inherit;
width: 100%;
transition: border-color var(--duration-fast) var(--ease-out);
} }
input:focus-visible, button:focus-visible, a:focus-visible { input[type="checkbox"], input[type="range"] { width: auto; }
outline: 2px solid var(--accent); input:hover, select:hover, textarea:hover { border-color: var(--muted); }
outline-offset: 2px; input:focus, select:focus, textarea:focus { border-color: var(--primary); }
} textarea { resize: vertical; line-height: 1.6; }
button { button, .btn {
background: var(--accent); background: var(--primary);
color: #150c07; color: var(--primary-inverse);
border: 0; border: 1px solid var(--primary);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.5rem 0.9rem; padding: var(--space-2) var(--space-4);
font: inherit; font: inherit;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
} }
button:hover { background: #ff7043; } button:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; background: var(--surface-raised);
border-color: var(--input-border); color: var(--muted); }
button.link { button.ghost {
background: none; background: transparent;
color: var(--accent); border-color: var(--input-border);
padding: 0; color: var(--text);
font-weight: normal; font-size: 0.85rem;
text-decoration: underline;
} }
table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; } button.ghost:hover { border-color: var(--primary); color: var(--primary);
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); } background: rgba(180, 120, 72, 0.05); }
th { font-family: var(--font-head); text-transform: uppercase; font-size: 0.8rem; color: var(--muted); }
tr.banned { opacity: 0.55; }
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; } button.danger { background: transparent; border-color: var(--pending); color: var(--pending); }
.actions form { display: flex; gap: 0.3rem; } button.danger:hover { background: var(--pending-bg); border-color: var(--unreviewed-hover);
.actions input { width: 10rem; } color: var(--error); }
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); } button.link { background: none; border: 0; color: var(--primary); padding: 0;
font-weight: 400; font-size: 0.9rem; text-decoration: underline; }
button.link:hover { background: none; color: var(--primary-hover); }
.invite { word-break: break-all; } .scorerow { display: flex; align-items: center; gap: var(--space-4); }
.scorerow input[type="range"] { flex: 1; accent-color: var(--primary); }
.scorerow output { font-family: var(--font-display); font-size: 2rem; font-weight: 700;
color: var(--gold-1); min-width: 3ch; text-align: right; }
/* Pulled up so it hugs the control. */
.scorescale { display: flex; justify-content: space-between; font-size: 0.9rem;
color: var(--muted); opacity: 0.6; margin-top: calc(-1 * var(--space-2)); }
/* --- drop zone --- */
/* Drop target that is the file input — the native control is hidden behind it, so keyboard
focus, validation and submission keep working. */
.dropzone { .dropzone {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.3rem; gap: var(--space-1);
padding: 2.2rem 1rem; padding: var(--space-6) var(--space-4);
border: 2px dashed var(--border); border: 2px dashed var(--input-border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface); background: var(--surface);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
transition: border-color 0.15s, background 0.15s; transition: border-color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
} }
.dropzone:hover { border-color: var(--accent); } .dropzone:hover { border-color: var(--primary); }
.dropzone.over { border-color: var(--accent); background: var(--surface-2); } .dropzone.over { border-color: var(--primary); background: var(--surface-raised); }
.dropzone.has-file { border-style: solid; border-color: var(--accent); } .dropzone.has-file { border-style: solid; border-color: var(--primary); }
.dropzone input[type="file"] { position: absolute; width: 1px; height: 1px; opacity: 0; }
/* Visually hidden, still focusable and still the thing that gets submitted. */ .dropzone:focus-within { box-shadow: var(--shadow-focus); }
.dropzone input[type="file"] { .dz-title { font-family: var(--font-display); text-transform: uppercase; letter-spacing: 0.02em; }
position: absolute; .filename { color: var(--primary); word-break: break-all; }
width: 1px;
height: 1px;
opacity: 0;
}
.dropzone:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.dz-title { font-family: var(--font-head); text-transform: uppercase; letter-spacing: 0.04em; }
.filename { color: var(--accent); word-break: break-all; }
.small { font-size: 0.85rem; }
select, textarea {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.6rem;
font: inherit;
}
textarea { resize: vertical; }
select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.player { width: 100%; margin: 0.6rem 0 1rem; }
.byline { color: var(--muted); margin-top: -0.3rem; }
.intro { white-space: pre-wrap; background: var(--surface); padding: 0.8rem 1rem;
border-left: 3px solid var(--border); border-radius: var(--radius); }
.empty { font-family: var(--font-head); text-transform: uppercase; color: var(--muted);
padding: 2rem 0; }
.nowrap { white-space: nowrap; }
.tag.done { background: var(--surface-2); color: var(--muted); }
.average { font-size: 1.1rem; }
.score { display: inline-block; font-family: var(--font-head); font-size: 1.1rem;
color: var(--accent); }
.review { background: var(--surface); border-radius: var(--radius); padding: 0.8rem 1rem;
margin-bottom: 0.8rem; }
.review header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.4rem; }
.review p { white-space: pre-wrap; margin: 0; }
.scorerow { display: flex; align-items: center; gap: 0.8rem; }
.scorerow input[type="range"] { flex: 1; accent-color: var(--accent); }
.scorerow output { font-family: var(--font-head); font-size: 1.3rem; color: var(--accent);
min-width: 2.5ch; text-align: right; }
.editbox { background: var(--surface); border-radius: var(--radius); padding: 0.6rem 1rem;
margin-bottom: 1.5rem; }
.editbox summary { cursor: pointer; font-family: var(--font-head); text-transform: uppercase; }
.editbox form { margin: 0.8rem 0; }
button.danger { background: var(--accent-2); color: var(--text); }
button.danger:hover { background: #e53935; }
.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.status { background: var(--surface); border-left: 3px solid var(--accent); border-radius: var(--radius);
padding: 0.8rem 1rem; margin-bottom: 1.2rem; }
.status p { margin: 0 0 0.5rem; }
button:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; }
.or { text-align: center; color: var(--muted); text-transform: uppercase; .or { text-align: center; color: var(--muted); text-transform: uppercase;
font-family: var(--font-head); margin: 0; } font-family: var(--font-display); margin: 0; }
/* --- submission status --- */
.status { background: var(--surface); border-left: 3px solid var(--primary);
border-radius: 0 var(--radius) var(--radius) 0; padding: var(--space-4);
margin-bottom: var(--space-5); }
.status p { margin: 0 0 var(--space-2); }
.status.failed { border-left-color: var(--pending); }
.saved { color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.editbox { background: var(--surface); border: 1px solid var(--hairline);
border-radius: var(--radius); padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-5); }
.editbox summary { cursor: pointer; font-family: var(--font-display);
text-transform: uppercase; color: var(--primary); }
.editbox form { margin: var(--space-4) 0; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-start; }
.actions form { display: flex; gap: var(--space-2); }
.actions input { width: 12rem; }
/* --- tables and admin --- */
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: var(--space-2); border-bottom: 1px solid var(--hairline); }
th { font-family: var(--font-display); text-transform: uppercase; font-size: 0.8rem;
font-weight: 600; color: var(--muted); }
tr.banned { opacity: 0.55; }
td.break { word-break: break-all; font-size: 0.8rem; }
.adminsection { background: var(--surface); border: 1px solid var(--hairline);
border-radius: var(--radius); margin-bottom: var(--space-6); }
.adminsection > header { background: var(--surface-header); padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--hairline); display: flex;
align-items: center; justify-content: space-between; gap: var(--space-4); }
.adminsection > header h2 { margin: 0; }
.adminsection .body { padding: var(--space-4) var(--space-5); }
.dot { display: inline-block; width: 0.9rem; height: 0.9rem; border-radius: 50%; }
.dot.on { background: #5a8f5a; }
.dot.off { background: #8f4a44; }
code { background: var(--surface-raised); padding: 0.1rem var(--space-1);
border-radius: var(--radius); font-size: 0.85rem; }
/* --- toasts --- */
.toasts { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 1000;
display: flex; flex-direction: column; gap: var(--space-2);
max-width: min(360px, 100vw - 24px); pointer-events: none; }
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-toast);
border-left: 4px solid var(--success);
background: var(--surface);
box-shadow: var(--shadow-card);
animation: toast-in var(--duration-fast) var(--ease-out);
}
.toast p { margin: 0; }
.toast .dismiss { background: none; border: 0; color: var(--text); font-size: 1.5rem;
line-height: 1; padding: 0; opacity: 0.7; cursor: pointer; }
.toast .dismiss:hover { opacity: 1; background: none; }
@keyframes toast-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: none; }
}
/* --- mobile --- */
.mobilenav { display: none; }
@media (max-width: 768px) {
.topbar-inner { grid-template-columns: 1fr auto; }
.navlinks, .userblock .lines { display: none; }
.mobilenav { display: block; justify-self: end; }
.mobilenav > summary {
list-style: none;
cursor: pointer;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--input-border);
border-radius: var(--radius);
font-size: 1.4rem;
color: var(--text);
transition: border-color var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
}
.mobilenav > summary::-webkit-details-marker { display: none; }
.mobilenav[open] > summary, .mobilenav > summary:hover {
border-color: var(--primary);
background: rgba(180, 120, 72, 0.05);
}
.mobilenav .panel {
position: absolute;
left: 0;
right: 0;
top: 100%;
background: var(--surface);
border-bottom: 2px solid var(--primary);
box-shadow: var(--shadow-card-hover);
padding: var(--space-5) var(--space-4) var(--space-4);
display: flex;
flex-direction: column;
z-index: 95;
animation: panel-down var(--duration-fast) var(--ease-out);
}
.mobilenav .panel a, .mobilenav .panel button {
font-family: var(--font-display);
text-transform: uppercase;
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: var(--space-4);
text-align: left;
background: none;
border: 0;
color: var(--text);
text-decoration: none;
}
main { padding: var(--space-5) var(--space-4); }
.toasts { left: var(--space-4); max-width: none; }
}
@keyframes panel-down {
from { transform: translateY(-100%); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* Keep the fades, drop the movement. */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
.songcard:hover { transform: none; }
}
+15 -9
View File
@@ -1,9 +1,12 @@
{{define "content"}} {{define "content"}}
<h1>Ylläpito</h1> <h1>Ylläpito</h1>
<section> <section class="adminsection">
<header>
<h2>Kutsut</h2> <h2>Kutsut</h2>
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form> <form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
</header>
<div class="body">
<table> <table>
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead> <thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
<tbody> <tbody>
@@ -11,12 +14,12 @@
<tr> <tr>
<td> <td>
{{if .IsValid}} {{if .IsValid}}
<a href="{{.Link}}" class="invite">{{.Link}}</a> <a href="{{.Link}}">{{.Link}}</a>
{{else}} {{else}}
<code class="muted">{{.Code}}</code> <code class="muted">{{.Code}}</code>
{{end}} {{end}}
</td> </td>
<td>{{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td> <td class="nowrap"><span class="dot {{if .IsValid}}on{{else}}off{{end}}"></span> {{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td>
<td>{{fidate .CreatedAt}}</td> <td>{{fidate .CreatedAt}}</td>
</tr> </tr>
{{else}} {{else}}
@@ -24,26 +27,28 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </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ä.</p>
</div>
</section> </section>
<section> <section class="adminsection">
<h2>Jäsenet</h2> <header><h2>Jäsenet</h2></header>
<div class="body">
<table> <table>
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead> <thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
<tbody> <tbody>
{{range .Data.Members}} {{range .Data.Members}}
<tr{{if .Banned}} class="banned"{{end}}> <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>{{.Email}}</td>
<td>{{fidate .CreatedAt}}</td> <td>{{fidate .CreatedAt}}</td>
<td class="actions"> <td class="actions">
<form method="post" action="/admin/users/{{.ID}}/ban"> <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>
<form method="post" action="/admin/users/{{.ID}}/password"> <form method="post" action="/admin/users/{{.ID}}/password">
<input type="password" name="password" placeholder="uusi salasana" required> <input type="password" name="password" placeholder="uusi salasana" required>
<button type="submit">Vaihda salasana</button> <button type="submit" class="ghost">Vaihda salasana</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -52,5 +57,6 @@
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
{{end}} {{end}}
+44 -10
View File
@@ -4,29 +4,63 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Levyraati</title> <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"> <link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
</head> </head>
<body> <body>
<header> <header class="topbar">
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a> <div class="topbar-inner">
<nav> <a class="brand" href="/">Levyraati{{if .Admin}} <span class="badge admin">ylläpito</span>{{end}}</a>
{{if .Admin}} {{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}} {{else if .Member}}
<nav class="navlinks">
<a href="/" {{if eq .Path "/"}}aria-current="page"{{end}}>Jono</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>
</nav>
<div class="userblock">
<span class="lines">
<span class="name">{{.Member.Name}}</span>
<span class="email">{{.Member.Email}}</span>
</span>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
<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</a> <a href="/">Jono</a>
<a href="/songs">Kappaleet</a> <a href="/songs">Kappaleet</a>
<a href="/submit">Lähetä</a> <a href="/submit">Lähetä</a>
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span> <form method="post" action="/logout"><button type="submit">Kirjaudu ulos</button></form>
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form> </div>
</details>
</div>
{{else}} {{else}}
<a href="/login">Kirjaudu</a> <span></span>
<nav class="navlinks"><a href="/login">Kirjaudu</a></nav>
{{end}} {{end}}
</nav> </div>
</header> </header>
{{with .Flash}}<p class="flash">{{.}}</p>{{end}} <main {{if .Narrow}}class="narrow"{{end}}>{{template "content" .}}</main>
<main>{{template "content" .}}</main> <footer class="sitefooter">Levyraati — kymmenen kaverin levyraati.</footer>
{{with .Flash}}
<div class="toasts">
<div class="toast" role="status">
<p>{{.}}</p>
<button class="dismiss" aria-label="Sulje"
onclick="this.closest('.toast').remove()">&times;</button>
</div>
</div>
{{end}}
</body> </body>
</html> </html>
+18 -15
View File
@@ -2,21 +2,21 @@
<audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio> <audio controls preload="none" src="/audio/{{.ID}}" class="player"></audio>
{{end}} {{end}}
{{define "songrow"}} {{define "songcard"}}
<tr> <a class="songcard{{if and (not .Own) (not .Reviewed)}} unreviewed{{end}}" href="/songs/{{.ID}}">
<td> {{with .Average}}<span class="scorebadge">{{score .}}</span>{{end}}
<a href="/songs/{{.ID}}">{{.Title}}</a> <span class="title">{{.Title}}</span>
<span class="muted">{{.Artist}}</span> <span class="artist">{{.Artist}}</span>
{{if .Own}}<span class="tag">oma</span>{{else if .Reviewed}}<span class="tag done">arvosteltu</span>{{end}} <span class="meta">
</td> <span class="badge genre">{{.GenreLabel}}</span>
<td class="nowrap">{{.GenreLabel}}</td> <span>{{.Length}}</span>
<td class="nowrap">{{.Length}}</td> <span>{{.Submitter}}</span>
<td class="nowrap"> {{if .Own}}<span class="badge admin">oma</span>
{{if .Average}}<strong>{{score .Average}}</strong> <span class="muted">({{.ReviewCount}})</span> {{else if .Reviewed}}<span class="badge reviewed">arvosteltu</span>
{{else if .ReviewCount}}<span class="muted">{{.ReviewCount}} arvostelua</span> {{else}}<span class="badge pending">arvostelematta</span>{{end}}
{{else}}<span class="muted"></span>{{end}} {{if .ReviewCount}}<span>{{.ReviewCount}} arvostelua</span>{{end}}
</td> </span>
</tr> </a>
{{end}} {{end}}
{{define "scorefield"}} {{define "scorefield"}}
@@ -27,4 +27,7 @@
<output>{{.}}</output> <output>{{.}}</output>
</span> </span>
</label> </label>
<span class="scorescale" aria-hidden="true">
<span>1</span><span>25</span><span>50</span><span>75</span><span>100</span>
</span>
{{end}} {{end}}
+3 -6
View File
@@ -4,12 +4,9 @@
{{if .Data.Items}} {{if .Data.Items}}
<p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun <p class="muted">Arvostelemattomat kappaleet, vanhimmasta uusimpaan. Pisteet paljastuvat kun
olet kirjoittanut oman arvostelusi.</p> olet kirjoittanut oman arvostelusi.</p>
<table> <div class="songgrid">
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Arvostelut</th></tr></thead> {{range .Data.Items}}{{template "songcard" .}}{{end}}
<tbody> </div>
{{range .Data.Items}}{{template "songrow" .}}{{end}}
</tbody>
</table>
{{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}} {{with .Data.NextCursor}}<p><a href="/?cursor={{.}}">Lisää →</a></p>{{end}}
{{else}} {{else}}
<p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p> <p class="empty">Jono on tyhjä. Olet arvostellut kaiken, mitä muut ovat lähettäneet.</p>
+11 -9
View File
@@ -1,10 +1,12 @@
{{define "content"}} {{define "content"}}
{{$s := .Data}} {{$s := .Data}}
<h1>{{$s.Title}}</h1> <h1>{{$s.Title}}</h1>
<p class="byline"> <p class="songmeta">
{{$s.Artist}} · {{$s.GenreLabel}} · {{$s.Length}} · <strong>{{$s.Artist}}</strong>
lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}} <span class="badge genre">{{$s.GenreLabel}}</span>
{{if $s.Own}}<span class="tag">oma kappale</span>{{end}} <span>{{$s.Length}}</span>
<span>lähettänyt {{$s.Submitter}} {{fidate $s.CreatedAt}}</span>
{{if $s.Own}}<span class="badge admin">oma kappale</span>{{end}}
</p> </p>
{{template "player" $s}} {{template "player" $s}}
@@ -31,7 +33,7 @@
</form> </form>
<form method="post" action="/songs/{{$s.ID}}/delete" <form method="post" action="/songs/{{$s.ID}}/delete"
onsubmit="return confirm('Poistetaanko kappale lopullisesti?')"> onsubmit="return confirm('Poistetaanko kappale lopullisesti?')">
<button type="submit" class="danger">Poista kappale</button> <button type="submit" class="danger ghost">Poista kappale</button>
</form> </form>
<p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p> <p class="muted small">Muokkaus ja poisto ovat mahdollisia vain ennen ensimmäistä arvostelua.</p>
</details> </details>
@@ -85,12 +87,12 @@
{{if $s.Revealed}} {{if $s.Revealed}}
{{if $s.Average}}<p class="average">Keskiarvo <strong>{{score $s.Average}}</strong></p>{{end}} {{if $s.Average}}<p class="average">Keskiarvo <strong>{{score $s.Average}}</strong></p>{{end}}
{{range $s.Reviews}} {{range $s.Reviews}}
<article class="review"> <article class="review{{if .Own}} own{{end}}">
<header> <header>
<span class="avatar">{{.Initials}}</span> <span class="avatar small">{{.Initials}}</span>
<strong>{{.Reviewer}}</strong> <span class="who">{{.Reviewer}}</span>
<span class="score">{{.Score}}</span> <span class="score">{{.Score}}</span>
<span class="muted small">{{fidate .CreatedAt}}</span> <span class="meta">{{fidate .CreatedAt}}</span>
</header> </header>
<p>{{.Text}}</p> <p>{{.Text}}</p>
</article> </article>
+3 -6
View File
@@ -2,12 +2,9 @@
<h1>Kappaleet</h1> <h1>Kappaleet</h1>
{{if .Data.Items}} {{if .Data.Items}}
<table> <div class="songgrid">
<thead><tr><th>Kappale</th><th>Genre</th><th>Kesto</th><th>Pisteet</th></tr></thead> {{range .Data.Items}}{{template "songcard" .}}{{end}}
<tbody> </div>
{{range .Data.Items}}{{template "songrow" .}}{{end}}
</tbody>
</table>
{{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}} {{with .Data.NextCursor}}<p><a href="/songs?cursor={{.}}">Vanhempia →</a></p>{{end}}
{{else}} {{else}}
<p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p> <p class="empty">Yhtään kappaletta ei ole vielä julkaistu.</p>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "submission-status"}} {{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}}>
<p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p> <p class="{{if .Failed}}error{{end}}"><strong>{{.Label}}</strong></p>
{{if .Failed}} {{if .Failed}}
{{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}} {{with .StatusMsg}}<p class="muted small">{{.}}</p>{{end}}