UX overhaul: card layout, admin overlay, status bar, and phase-safe inputs

This commit is contained in:
2026-01-28 15:37:11 +01:00
parent 16e7067902
commit abd0720821
3 changed files with 230 additions and 108 deletions

View File

@@ -49,11 +49,13 @@ async function loadState() {
renderPhasePill(); renderPhasePill();
renderCounts(); renderCounts();
const nameInput = $("name-input"); const nameInput = $("name-input");
if (!nameInput.dataset.userEditing) { if (nameInput && !nameInput.dataset.userEditing) {
nameInput.value = me.displayName || ""; nameInput.value = me.displayName || "";
} }
if ($("player-id")) {
$("player-id").textContent = `Player ID: ${me.id}`; $("player-id").textContent = `Player ID: ${me.id}`;
} }
}
async function loadSuggestData() { async function loadSuggestData() {
if (state.phase !== "Suggest") return; if (state.phase !== "Suggest") return;
@@ -92,35 +94,28 @@ function renderPhasePill() {
const id = viewMap[state.phase]; const id = viewMap[state.phase];
if (id) $(id).classList.remove("hidden"); if (id) $(id).classList.remove("hidden");
const phaseSelect = $("phase-select"); const phaseSelect = $("phase-select");
if (!phaseSelect.dataset.userEditing) { if (phaseSelect && !phaseSelect.dataset.userEditing) {
phaseSelect.value = state.phase || "Suggest"; phaseSelect.value = state.phase || "Suggest";
} }
} }
function renderCounts() { function renderCounts() {
if (!state.counts) return; if (!state.counts) return;
$("phase-description").textContent = `Phase: ${state.phase}`;
$("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`; $("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`;
} }
function renderMySuggestions() { function renderMySuggestions() {
const list = $("my-suggestions"); const wrap = $("my-suggestions");
list.innerHTML = ""; if (!wrap) return;
state.mySuggestions.forEach((s) => { wrap.innerHTML = "";
const li = document.createElement("li"); state.mySuggestions.forEach((s) => wrap.appendChild(buildCard(s, { showAuthor: false })));
li.innerHTML = `<strong>${s.name}</strong>${s.genre ? ` · ${s.genre}` : ""}<br>${s.description || ""}`;
list.appendChild(li);
});
} }
function renderAllSuggestions() { function renderAllSuggestions() {
const list = $("all-suggestions"); const list = $("all-suggestions");
if (!list) return;
list.innerHTML = ""; list.innerHTML = "";
state.allSuggestions.forEach((s) => { state.allSuggestions.forEach((s) => list.appendChild(buildCard(s, { showAuthor: true })));
const li = document.createElement("li");
li.innerHTML = `<strong>${s.name}</strong> by ${s.author || "Anonymous"}${s.genre ? ` · ${s.genre}` : ""}<br>${s.description || ""}`;
list.appendChild(li);
});
} }
function renderVotes() { function renderVotes() {
@@ -128,18 +123,14 @@ function renderVotes() {
list.innerHTML = ""; list.innerHTML = "";
const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score]));
state.allSuggestions.forEach((s) => { state.allSuggestions.forEach((s) => {
const li = document.createElement("li"); const li = buildCard(s, { showAuthor: true });
const current = votesMap[s.id] ?? 0; const current = votesMap[s.id] ?? 0;
li.innerHTML = ` const footer = document.createElement("div");
<div class="vote-row"> footer.className = "vote-controls";
<div> footer.innerHTML = `
<strong>${s.name}</strong> by ${s.author || "Anonymous"}
</div>
<div class="vote-controls">
<input type="range" min="0" max="10" value="${current}" data-id="${s.id}"> <input type="range" min="0" max="10" value="${current}" data-id="${s.id}">
<span class="score" id="score-${s.id}">${current}</span> <span class="score" id="score-${s.id}">${current}</span>`;
</div> li.querySelector(".card-body").appendChild(footer);
</div>`;
list.appendChild(li); list.appendChild(li);
}); });
list.querySelectorAll("input[type=range]").forEach((input) => { list.querySelectorAll("input[type=range]").forEach((input) => {
@@ -165,18 +156,26 @@ function renderResults() {
const list = $("results-list"); const list = $("results-list");
list.innerHTML = ""; list.innerHTML = "";
state.results.forEach((r) => { state.results.forEach((r) => {
const li = document.createElement("li"); const card = buildCard({
li.innerHTML = `<strong>${r.name}</strong> — ${r.total} pts (${r.count} votes, avg ${r.average.toFixed(1)})${r.author ? ` · ${r.author}` : ""}`; id: r.id,
list.appendChild(li); name: r.name,
genre: `${r.total} pts • ${r.count} votes • avg ${r.average.toFixed(1)}`,
description: r.author ? `By ${r.author}` : "",
screenshotUrl: r.screenshotUrl,
youtubeUrl: r.youtubeUrl
}, { showAuthor: false });
list.appendChild(card);
}); });
} }
function setupHandlers() { function setupHandlers() {
const nameInput = $("name-input"); const nameInput = $("name-input");
if (nameInput) {
["focus", "input"].forEach(evt => { ["focus", "input"].forEach(evt => {
nameInput.addEventListener(evt, () => { nameInput.dataset.userEditing = "1"; }); nameInput.addEventListener(evt, () => { nameInput.dataset.userEditing = "1"; });
}); });
nameInput.addEventListener("blur", () => { nameInput.dataset.userEditing = ""; }); nameInput.addEventListener("blur", () => { nameInput.dataset.userEditing = ""; });
}
$("save-name").addEventListener("click", async () => { $("save-name").addEventListener("click", async () => {
const name = nameInput.value.trim(); const name = nameInput.value.trim();
@@ -232,6 +231,13 @@ function setupHandlers() {
$("reset").addEventListener("click", () => adminAction("/api/admin/reset", "Reset complete")); $("reset").addEventListener("click", () => adminAction("/api/admin/reset", "Reset complete"));
$("factory-reset").addEventListener("click", () => adminAction("/api/admin/factory-reset", "Factory reset complete")); $("factory-reset").addEventListener("click", () => adminAction("/api/admin/factory-reset", "Factory reset complete"));
const adminToggle = $("admin-toggle");
const adminCard = $("admin-card");
const adminClose = $("admin-close");
const togglePanel = (show) => adminCard.classList.toggle("hidden", !show);
adminToggle.addEventListener("click", () => togglePanel(!adminCard.classList.contains("hidden")));
adminClose.addEventListener("click", () => togglePanel(false));
} }
async function adminAction(path, successMessage) { async function adminAction(path, successMessage) {
@@ -250,6 +256,25 @@ async function refreshPhaseData() {
await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]); await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]);
} }
function buildCard(s, { showAuthor }) {
const card = document.createElement("article");
card.className = "game-card";
const hasImage = !!s.screenshotUrl;
card.innerHTML = `
<div class="card-visual" style="${hasImage ? `background-image:url('${s.screenshotUrl}')` : ''}"></div>
<div class="card-body">
<div class="card-title-row">
<h3>${s.name}</h3>
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
</div>
${s.genre ? `<p class="muted">${s.genre}</p>` : ""}
${s.description ? `<p>${s.description}</p>` : ""}
${s.youtubeUrl ? `<a class="link" href="${s.youtubeUrl}" target="_blank" rel="noopener">YouTube ↗</a>` : ""}
</div>
`;
return card;
}
async function main() { async function main() {
setupHandlers(); setupHandlers();
try { try {

View File

@@ -7,34 +7,29 @@
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<header class="hero"> <div class="status-bar">
<div> <span class="status-dot"></span>
<h1>CoopGameChooser</h1> <span id="phase-pill">Loading…</span>
<p class="subtitle">Blind suggestions, blind votes, quick decision.</p> <span class="counts" id="counts"></span>
</div> </div>
<div class="phase-pill" id="phase-pill">Loading…</div>
</header>
<main class="grid"> <main class="grid">
<section class="card" id="identity-card">
<h2>Your Name</h2>
<label class="stack">
<span class="label">Display name</span>
<input id="name-input" maxlength="64" placeholder="Pick a name" />
</label>
<button id="save-name">Save</button>
<p class="hint" id="player-id"></p>
</section>
<section class="card" id="phase-card">
<h2>Current Phase</h2>
<p id="phase-description">Loading…</p>
<p class="hint" id="counts"></p>
</section>
<section class="card" id="actions-card"> <section class="card" id="actions-card">
<div id="suggest-view" class="phase-view hidden"> <div id="suggest-view" class="phase-view hidden">
<div class="split">
<div>
<h2>Suggest (up to 3)</h2> <h2>Suggest (up to 3)</h2>
<p class="hint">Only you can see your suggestions until Reveal.</p>
</div>
<div class="name-box">
<label class="stack">
<span class="label">Your name</span>
<input id="name-input" maxlength="64" placeholder="Pick a name" />
</label>
<button id="save-name" class="ghost">Save</button>
<p class="hint" id="player-id"></p>
</div>
</div>
<form id="suggest-form" class="stack"> <form id="suggest-form" class="stack">
<input name="name" required maxlength="100" placeholder="Game name *" /> <input name="name" required maxlength="100" placeholder="Game name *" />
<input name="genre" maxlength="50" placeholder="Genre" /> <input name="genre" maxlength="50" placeholder="Genre" />
@@ -45,27 +40,32 @@
</div> </div>
<button type="submit">Submit</button> <button type="submit">Submit</button>
</form> </form>
<ul id="my-suggestions" class="list"></ul> <div id="my-suggestions" class="card-grid"></div>
</div> </div>
<div id="reveal-view" class="phase-view hidden"> <div id="reveal-view" class="phase-view hidden">
<h2>All Suggestions</h2> <h2>All Suggestions</h2>
<ul id="all-suggestions" class="list"></ul> <div id="all-suggestions" class="card-grid"></div>
</div> </div>
<div id="vote-view" class="phase-view hidden"> <div id="vote-view" class="phase-view hidden">
<h2>Vote 010</h2> <h2>Vote 010</h2>
<ul id="vote-list" class="list"></ul> <div id="vote-list" class="card-grid"></div>
</div> </div>
<div id="results-view" class="phase-view hidden"> <div id="results-view" class="phase-view hidden">
<h2>Results</h2> <h2>Results</h2>
<ol id="results-list" class="list"></ol> <div id="results-list" class="card-grid results-grid"></div>
</div> </div>
</section> </section>
</main>
<section class="card" id="admin-card"> <button id="admin-toggle" class="admin-toggle" title="Admin tools">•••</button>
<h2>Admin</h2> <section class="card admin-panel hidden" id="admin-card">
<div class="panel-header">
<h3>Admin</h3>
<button id="admin-close" class="ghost"></button>
</div>
<label class="stack"> <label class="stack">
<span class="label">Admin key</span> <span class="label">Admin key</span>
<input id="admin-key" type="password" placeholder="X-Admin-Key" /> <input id="admin-key" type="password" placeholder="X-Admin-Key" />
@@ -84,7 +84,6 @@
<button id="factory-reset" class="danger">Factory reset</button> <button id="factory-reset" class="danger">Factory reset</button>
</div> </div>
</section> </section>
</main>
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>

View File

@@ -1,43 +1,42 @@
:root { :root {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at 20% 20%, #1f2937, #0b1224); background: radial-gradient(circle at 20% 20%, #0f172a, #050816);
color: #e5e7eb; color: #e5e7eb;
} }
body { body {
margin: 0; margin: 0;
padding: 24px; padding: 20px;
} }
.hero { .status-bar {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
background: linear-gradient(135deg, #111827, #0f172a); gap: 10px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.8);
border: 1px solid #1f2937; border: 1px solid #1f2937;
border-radius: 12px; border-radius: 10px;
padding: 16px 20px; box-shadow: 0 10px 24px rgba(0,0,0,0.25);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); max-width: 540px;
} }
.subtitle { .status-dot {
margin: 4px 0 0; width: 10px;
height: 10px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 10px #22c55e;
}
.counts {
color: #9ca3af; color: #9ca3af;
} font-size: 13px;
.phase-pill {
padding: 8px 12px;
background: #1d4ed8;
color: white;
border-radius: 999px;
font-weight: 600;
min-width: 96px;
text-align: center;
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: 1fr;
gap: 16px; gap: 16px;
margin-top: 16px; margin-top: 16px;
} }
@@ -55,6 +54,23 @@ body {
margin-bottom: 8px; margin-bottom: 8px;
} }
.split {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.name-box {
min-width: 220px;
max-width: 260px;
background: #0b1224;
border: 1px solid #1f2937;
padding: 10px;
border-radius: 10px;
}
.stack { .stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -92,16 +108,70 @@ button.danger {
border-color: #b91c1c; border-color: #b91c1c;
} }
button.ghost {
background: transparent;
border-color: #374151;
}
.label { color: #9ca3af; font-size: 12px; } .label { color: #9ca3af; font-size: 12px; }
.hint { color: #9ca3af; font-size: 12px; margin: 8px 0 0; } .hint { color: #9ca3af; font-size: 12px; margin: 8px 0 0; }
.list { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-direction: column; gap: 10px; } .card-grid {
.list li { padding: 12px; border: 1px solid #1f2937; border-radius: 8px; background: #0b1224; } display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin-top: 12px;
}
.vote-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; } .game-card {
.vote-controls { display: flex; gap: 10px; align-items: center; min-width: 180px; } background: #0b1224;
border: 1px solid #1f2937;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 220px;
}
.card-visual {
height: 140px;
background: linear-gradient(135deg, #1d4ed8, #22c55e);
background-size: cover;
background-position: center;
}
.card-body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.card-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.card-title-row h3 { margin: 0; font-size: 18px; }
.muted { color: #9ca3af; margin: 0; }
.link { color: #93c5fd; text-decoration: none; font-weight: 600; }
.link:hover { text-decoration: underline; }
.chip {
background: #1f2937;
color: #e5e7eb;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
}
.vote-controls { display: flex; gap: 10px; align-items: center; margin-top: 6px; }
.score { font-weight: 700; } .score { font-weight: 700; }
.results-grid .game-card { border-color: #2563eb44; }
.hidden { display: none !important; } .hidden { display: none !important; }
.toast { .toast {
@@ -116,3 +186,31 @@ button.danger {
max-width: 320px; max-width: 320px;
} }
.toast.error { background: #dc2626; } .toast.error { background: #dc2626; }
.admin-toggle {
position: fixed;
bottom: 18px;
right: 18px;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid #1f2937;
background: #0f172a;
color: #9ca3af;
font-weight: 700;
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
}
.admin-panel {
position: fixed;
bottom: 70px;
right: 18px;
width: 320px;
z-index: 20;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
}