UX overhaul: card layout, admin overlay, status bar, and phase-safe inputs
This commit is contained in:
@@ -49,10 +49,12 @@ async function loadState() {
|
||||
renderPhasePill();
|
||||
renderCounts();
|
||||
const nameInput = $("name-input");
|
||||
if (!nameInput.dataset.userEditing) {
|
||||
if (nameInput && !nameInput.dataset.userEditing) {
|
||||
nameInput.value = me.displayName || "";
|
||||
}
|
||||
$("player-id").textContent = `Player ID: ${me.id}`;
|
||||
if ($("player-id")) {
|
||||
$("player-id").textContent = `Player ID: ${me.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSuggestData() {
|
||||
@@ -92,35 +94,28 @@ function renderPhasePill() {
|
||||
const id = viewMap[state.phase];
|
||||
if (id) $(id).classList.remove("hidden");
|
||||
const phaseSelect = $("phase-select");
|
||||
if (!phaseSelect.dataset.userEditing) {
|
||||
if (phaseSelect && !phaseSelect.dataset.userEditing) {
|
||||
phaseSelect.value = state.phase || "Suggest";
|
||||
}
|
||||
}
|
||||
|
||||
function renderCounts() {
|
||||
if (!state.counts) return;
|
||||
$("phase-description").textContent = `Phase: ${state.phase}`;
|
||||
$("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`;
|
||||
}
|
||||
|
||||
function renderMySuggestions() {
|
||||
const list = $("my-suggestions");
|
||||
list.innerHTML = "";
|
||||
state.mySuggestions.forEach((s) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<strong>${s.name}</strong>${s.genre ? ` · ${s.genre}` : ""}<br>${s.description || ""}`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
const wrap = $("my-suggestions");
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = "";
|
||||
state.mySuggestions.forEach((s) => wrap.appendChild(buildCard(s, { showAuthor: false })));
|
||||
}
|
||||
|
||||
function renderAllSuggestions() {
|
||||
const list = $("all-suggestions");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
state.allSuggestions.forEach((s) => {
|
||||
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);
|
||||
});
|
||||
state.allSuggestions.forEach((s) => list.appendChild(buildCard(s, { showAuthor: true })));
|
||||
}
|
||||
|
||||
function renderVotes() {
|
||||
@@ -128,18 +123,14 @@ function renderVotes() {
|
||||
list.innerHTML = "";
|
||||
const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score]));
|
||||
state.allSuggestions.forEach((s) => {
|
||||
const li = document.createElement("li");
|
||||
const li = buildCard(s, { showAuthor: true });
|
||||
const current = votesMap[s.id] ?? 0;
|
||||
li.innerHTML = `
|
||||
<div class="vote-row">
|
||||
<div>
|
||||
<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}">
|
||||
<span class="score" id="score-${s.id}">${current}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "vote-controls";
|
||||
footer.innerHTML = `
|
||||
<input type="range" min="0" max="10" value="${current}" data-id="${s.id}">
|
||||
<span class="score" id="score-${s.id}">${current}</span>`;
|
||||
li.querySelector(".card-body").appendChild(footer);
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.querySelectorAll("input[type=range]").forEach((input) => {
|
||||
@@ -165,18 +156,26 @@ function renderResults() {
|
||||
const list = $("results-list");
|
||||
list.innerHTML = "";
|
||||
state.results.forEach((r) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<strong>${r.name}</strong> — ${r.total} pts (${r.count} votes, avg ${r.average.toFixed(1)})${r.author ? ` · ${r.author}` : ""}`;
|
||||
list.appendChild(li);
|
||||
const card = buildCard({
|
||||
id: r.id,
|
||||
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() {
|
||||
const nameInput = $("name-input");
|
||||
["focus", "input"].forEach(evt => {
|
||||
nameInput.addEventListener(evt, () => { nameInput.dataset.userEditing = "1"; });
|
||||
});
|
||||
nameInput.addEventListener("blur", () => { nameInput.dataset.userEditing = ""; });
|
||||
if (nameInput) {
|
||||
["focus", "input"].forEach(evt => {
|
||||
nameInput.addEventListener(evt, () => { nameInput.dataset.userEditing = "1"; });
|
||||
});
|
||||
nameInput.addEventListener("blur", () => { nameInput.dataset.userEditing = ""; });
|
||||
}
|
||||
|
||||
$("save-name").addEventListener("click", async () => {
|
||||
const name = nameInput.value.trim();
|
||||
@@ -232,6 +231,13 @@ function setupHandlers() {
|
||||
|
||||
$("reset").addEventListener("click", () => adminAction("/api/admin/reset", "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) {
|
||||
@@ -250,6 +256,25 @@ async function refreshPhaseData() {
|
||||
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() {
|
||||
setupHandlers();
|
||||
try {
|
||||
|
||||
@@ -7,34 +7,29 @@
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>CoopGameChooser</h1>
|
||||
<p class="subtitle">Blind suggestions, blind votes, quick decision.</p>
|
||||
</div>
|
||||
<div class="phase-pill" id="phase-pill">Loading…</div>
|
||||
</header>
|
||||
<div class="status-bar">
|
||||
<span class="status-dot"></span>
|
||||
<span id="phase-pill">Loading…</span>
|
||||
<span class="counts" id="counts">—</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div id="suggest-view" class="phase-view hidden">
|
||||
<h2>Suggest (up to 3)</h2>
|
||||
<div class="split">
|
||||
<div>
|
||||
<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">
|
||||
<input name="name" required maxlength="100" placeholder="Game name *" />
|
||||
<input name="genre" maxlength="50" placeholder="Genre" />
|
||||
@@ -45,47 +40,51 @@
|
||||
</div>
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
<ul id="my-suggestions" class="list"></ul>
|
||||
<div id="my-suggestions" class="card-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="reveal-view" class="phase-view hidden">
|
||||
<h2>All Suggestions</h2>
|
||||
<ul id="all-suggestions" class="list"></ul>
|
||||
<div id="all-suggestions" class="card-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="vote-view" class="phase-view hidden">
|
||||
<h2>Vote 0–10</h2>
|
||||
<ul id="vote-list" class="list"></ul>
|
||||
<div id="vote-list" class="card-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-view" class="phase-view hidden">
|
||||
<h2>Results</h2>
|
||||
<ol id="results-list" class="list"></ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="admin-card">
|
||||
<h2>Admin</h2>
|
||||
<label class="stack">
|
||||
<span class="label">Admin key</span>
|
||||
<input id="admin-key" type="password" placeholder="X-Admin-Key" />
|
||||
</label>
|
||||
<div class="stack horizontal">
|
||||
<select id="phase-select">
|
||||
<option>Suggest</option>
|
||||
<option>Reveal</option>
|
||||
<option>Vote</option>
|
||||
<option>Results</option>
|
||||
</select>
|
||||
<button id="set-phase">Set phase</button>
|
||||
</div>
|
||||
<div class="stack horizontal">
|
||||
<button id="reset" class="danger">Reset (keep players)</button>
|
||||
<button id="factory-reset" class="danger">Factory reset</button>
|
||||
<div id="results-list" class="card-grid results-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<button id="admin-toggle" class="admin-toggle" title="Admin tools">•••</button>
|
||||
<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">
|
||||
<span class="label">Admin key</span>
|
||||
<input id="admin-key" type="password" placeholder="X-Admin-Key" />
|
||||
</label>
|
||||
<div class="stack horizontal">
|
||||
<select id="phase-select">
|
||||
<option>Suggest</option>
|
||||
<option>Reveal</option>
|
||||
<option>Vote</option>
|
||||
<option>Results</option>
|
||||
</select>
|
||||
<button id="set-phase">Set phase</button>
|
||||
</div>
|
||||
<div class="stack horizontal">
|
||||
<button id="reset" class="danger">Reset (keep players)</button>
|
||||
<button id="factory-reset" class="danger">Factory reset</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
:root {
|
||||
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;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 10px #22c55e;
|
||||
}
|
||||
|
||||
.counts {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.phase-pill {
|
||||
padding: 8px 12px;
|
||||
background: #1d4ed8;
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
min-width: 96px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -55,6 +54,23 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -92,16 +108,70 @@ button.danger {
|
||||
border-color: #b91c1c;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.label { color: #9ca3af; font-size: 12px; }
|
||||
.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; }
|
||||
.list li { padding: 12px; border: 1px solid #1f2937; border-radius: 8px; background: #0b1224; }
|
||||
.card-grid {
|
||||
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; }
|
||||
.vote-controls { display: flex; gap: 10px; align-items: center; min-width: 180px; }
|
||||
.game-card {
|
||||
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; }
|
||||
|
||||
.results-grid .game-card { border-color: #2563eb44; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.toast {
|
||||
@@ -116,3 +186,31 @@ button.danger {
|
||||
max-width: 320px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user