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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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. 400–700 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
@@ -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
@@ -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()">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user