feat: add boilerplate
This commit is contained in:
185
app/static/js/play.js
Normal file
185
app/static/js/play.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
Reference in New Issue
Block a user