Add the submission pipeline and the review loop

Steps 3 and 4 of the build order. A member can now upload a song, watch it
convert, publish it, and review what everyone else has published.

Pipeline:
- ffprobe reads tags synchronously at submit so prefill never races typing;
  ffmpeg converts to Opus in the background, two at a time
- ffmpeg succeeding is the validation — no container sniffing
- publish moves the file inside the transaction, so a song row and its .ogg
  appear together or neither does
- five submissions per rolling 24h, failures excluded

Reviews and the reveal rule:
- the queue is unreviewed songs only, oldest first, never your own
- other people's reviews and the average are withheld in the query, not the
  template — a hidden average is never sent
- 30 minutes to edit or delete your own review, enforced in the WHERE clause
- deleting the last review unlocks the song for its submitter again

The waiting page has one button: the metadata form autosaves after a pause in
typing, and Julkaise submits it and publishes in the same request, so nothing
is lost without JS.

Genres store an English code and render a Finnish label.
This commit is contained in:
Esa Kataja
2026-07-31 21:42:49 +03:00
parent 41c8a2914f
commit 80d3e36679
19 changed files with 2040 additions and 16 deletions
+83
View File
@@ -145,3 +145,86 @@ tr.banned { opacity: 0.55; }
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); }
.invite { word-break: break-all; }
/* Drop target that is the file input — the native control is hidden behind it, so keyboard
focus, validation and submission keep working. */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 2.2rem 1rem;
border: 2px dashed var(--border);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
.dropzone:hover { border-color: var(--accent); }
.dropzone.over { border-color: var(--accent); background: var(--surface-2); }
.dropzone.has-file { border-style: solid; border-color: var(--accent); }
/* Visually hidden, still focusable and still the thing that gets submitted. */
.dropzone input[type="file"] {
position: absolute;
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; }