feat: add boilerplate

This commit is contained in:
2026-10-26 21:56:23 +01:00
commit cc6675f7c6
29 changed files with 2231 additions and 0 deletions

185
app/static/js/play.js Normal file
View File

@@ -0,0 +1,185 @@
const root = document.querySelector(".play-layout");
const sessionId = root.dataset.session;
const token = root.dataset.token;
const isHost = root.dataset.host === "1";
const playerId = Number(root.dataset.player);
let roundSeen = 0;
const playersEl = document.getElementById("players");
const stageEl = document.getElementById("stage");
const tipEl = document.getElementById("tip");
const inviteEl = document.getElementById("invite");
const hostForm = document.getElementById("host-form");
const startBtn = document.getElementById("start-btn");
const nextBtn = document.getElementById("next-btn");
inviteEl.textContent = "Invite: " + location.origin + "/join/" + sessionId;
let lastChoice = null;
let submitted = false;
let proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws/${sessionId}?token=${token}`);
ws.onmessage = (ev) => {
const data = JSON.parse(ev.data);
if (data.type === "error") {
tipEl.textContent = data.message;
return;
}
if (data.type === "state") render(data);
};
function send(payload) {
ws.send(JSON.stringify(payload));
}
if (startBtn) startBtn.onclick = () => send({ action: "start" });
if (nextBtn) nextBtn.onclick = () => send({ action: "next" });
function render(data) {
const s = data.session;
playersEl.innerHTML = data.players
.map(
(p) =>
`<li class="${p.host ? "host" : ""} ${p.connected ? "" : "off"}">${escapeHtml(p.name)} — ${p.score} pts</li>`
)
.join("");
if (hostForm) hostForm.hidden = s.status !== "lobby";
if (startBtn) startBtn.hidden = s.status !== "lobby";
if (nextBtn) nextBtn.hidden = !(s.status === "playing" && data.round && data.round.status === "revealed");
if (s.status === "lobby") {
submitted = false;
lastChoice = null;
tipEl.textContent = "Lobby time! Host tweaks the rules, then hits START. Share the invite link.";
stageEl.innerHTML = `<p>Waiting for players (${data.players.length}/${s.max_players}). Rounds: ${s.num_rounds}.</p>`;
return;
}
if (s.status === "finished") {
tipEl.textContent = `Match over! ${data.winner} takes the crown. High score wins the RAL throne.`;
stageEl.innerHTML = `<h2>FINAL BOARD</h2>` + playersEl.outerHTML;
return;
}
const r = data.round;
if (!r) return;
if (r.number !== roundSeen) {
roundSeen = r.number;
lastChoice = null;
submitted = false;
}
if ((r.answered_ids || []).includes(playerId)) submitted = true;
if (r.status === "open") {
if (r.bonus) {
tipEl.textContent =
"BONUS ROUND! Harder posting. 150 for the right band, 300 if you nail the exact RAL, -50 if the exact guess is wrong. Also pick hybrid / remote / in-place (+50).";
} else {
tipEl.textContent =
"Read the offer. Pick the closest RAL band. 100 points if you hit it, 0 if not. Optional exact number: +200 or -50.";
}
} else {
tipEl.textContent = "Everyone locked in. Here's the reveal — host can move to the next round.";
}
const job = r.job;
const meta = [];
if (r.bonus) meta.push(`<span class="chip bonus">BONUS ROUND</span>`);
meta.push(`<span class="chip">ROUND ${r.number}/${s.num_rounds}</span>`);
if (job.company) meta.push(`<span class="chip">${escapeHtml(job.company)}</span>`);
if (job.subject) meta.push(`<span class="chip">${escapeHtml(job.subject)}</span>`);
if (job.location) meta.push(`<span class="chip">${escapeHtml(job.location)}</span>`);
if (job.extra) meta.push(`<span class="chip">${escapeHtml(job.extra)}</span>`);
const locked = r.status !== "open" || submitted;
const options = r.options
.map((o) => {
let cls = "opt";
if (lastChoice === o.key) cls += " picked";
if (r.status === "revealed") {
if (o.key === r.correct) cls += " good";
else if (lastChoice === o.key) cls += " bad";
}
return `<button class="${cls}" data-key="${o.key}" ${locked && r.status === "open" ? "disabled" : ""}>${escapeHtml(o.label)}</button>`;
})
.join("");
let extra = "";
if (job.has_exact) {
extra += `<label>EXACT RAL (optional)<input id="precise" type="number" min="15000" max="400000" step="1000" ${locked ? "disabled" : ""} /></label>`;
}
if (r.bonus) {
extra += `<label>WORK MODE
<select id="work-type" ${locked ? "disabled" : ""}>
<option value="hybrid">Hybrid</option>
<option value="remote">Full remote</option>
<option value="onsite">In-place only</option>
</select>
</label>`;
}
let results = "";
if (r.status === "revealed") {
const ral =
r.ral_exact != null
? `Exact RAL: ${r.ral_exact}`
: `RAL range: ${r.ral_min} - ${r.ral_max}`;
results = `<div class="results">
<p>${ral} · Company: ${escapeHtml(r.company)} · Work: ${escapeHtml(r.work_type)}</p>
<ul>${r.answers
.map(
(a) =>
`<li>${escapeHtml(a.name)}: ${a.correct ? "HIT" : "MISS"} ${a.precise != null ? "· exact " + a.precise : ""} → ${a.points} pts</li>`
)
.join("")}</ul>
</div>`;
} else {
results = `<p class="tiny">Locked in: ${r.answered.join(", ") || "nobody yet"}. Waiting: ${r.waiting.join(", ")}</p>`;
}
stageEl.innerHTML = `
<p class="job-title">${escapeHtml(job.title)}</p>
<div class="meta">${meta.join("")}</div>
<div class="description">${escapeHtml(job.description)}</div>
<div class="options">${options}</div>
${extra}
${r.status === "open" ? `<button class="btn" id="lock-btn" ${locked ? "disabled" : ""}>LOCK ANSWER</button>` : ""}
${results}
`;
stageEl.querySelectorAll(".opt").forEach((btn) => {
btn.onclick = () => {
if (locked) return;
lastChoice = btn.dataset.key;
stageEl.querySelectorAll(".opt").forEach((b) => b.classList.remove("picked"));
btn.classList.add("picked");
};
});
const lockBtn = document.getElementById("lock-btn");
if (lockBtn) {
lockBtn.onclick = () => {
if (!lastChoice) {
tipEl.textContent = "Pick a RAL band first, then lock it in.";
return;
}
const preciseEl = document.getElementById("precise");
const workEl = document.getElementById("work-type");
submitted = true;
send({
action: "answer",
choice: lastChoice,
precise: preciseEl && preciseEl.value ? Number(preciseEl.value) : null,
work_type: workEl ? workEl.value : "",
});
};
}
}
function escapeHtml(str) {
return String(str || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}