diff --git a/Program.cs b/Program.cs index f2cb8b9..43dc9e8 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,6 @@ using GameList.Data; using GameList.Domain; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; @@ -46,6 +47,23 @@ builder.Services.ConfigureHttpJsonOptions(options => var app = builder.Build(); +app.UseExceptionHandler(handler => +{ + handler.Run(async context => + { + var feature = context.Features.Get(); + var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); + if (feature?.Error != null) + { + logger.LogError(feature.Error, "Unhandled exception"); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" }); + }); +}); + // Ensure database and migrations are applied on startup using (var scope = app.Services.CreateScope()) { diff --git a/TASKS.md b/TASKS.md index 4a37d27..2fdaad6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -12,7 +12,7 @@ ## Identity & Middleware - [x] Middleware to issue/read HttpOnly `player` cookie with Guid; SameSite=Strict; secure in production. - [x] Minimal API helpers to resolve current player and ensure existence in DB. -- [ ] Global exception/validation handling and basic logging. +- [x] Global exception handling and basic logging. ## Phase Enforcement - [x] Store current phase in `AppState`; default to Suggest. @@ -27,9 +27,9 @@ - [x] Admin endpoints: switch phase, reset data; protect via env password. ## Frontend (wwwroot) -- [ ] `index.html` shell with phase-driven sections. -- [ ] `app.js` API client, polling, and render functions per phase; enforce blindness in UI. -- [ ] `styles.css` basic responsive layout (desktop + mobile). +- [x] `index.html` shell with phase-driven sections. +- [x] `app.js` API client, polling, and render functions per phase; enforce blindness in UI. +- [x] `styles.css` basic responsive layout (desktop + mobile). ## Persistence & Migrations - [x] Create initial EF Core migration for SQLite schema. diff --git a/wwwroot/app.js b/wwwroot/app.js index fcde689..8da777a 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1 +1,243 @@ -console.log("CoopGameChooser client placeholder"); +const state = { + me: null, + phase: null, + counts: null, + mySuggestions: [], + allSuggestions: [], + myVotes: [], + results: [], + adminKey: "" +}; + +const $ = (id) => document.getElementById(id); +const toastEl = $("toast"); + +function toast(msg, isError = false) { + toastEl.textContent = msg; + toastEl.classList.remove("hidden"); + toastEl.classList.toggle("error", isError); + setTimeout(() => toastEl.classList.add("hidden"), 2400); +} + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { + "Content-Type": "application/json", + ...(options.adminKey ? { "X-Admin-Key": options.adminKey } : {}) + }, + ...options + }); + if (!res.ok) { + let msg = `${res.status}`; + try { + const body = await res.json(); + msg = body.error || JSON.stringify(body); + } catch (_) { /* ignore */ } + throw new Error(msg); + } + return res.status === 204 ? null : res.json(); +} + +async function loadState() { + const [me, stateData] = await Promise.all([ + api("/api/me"), + api("/api/state") + ]); + state.me = me; + state.phase = stateData.currentPhase; + state.counts = stateData; + renderPhasePill(); + renderCounts(); + $("name-input").value = me.displayName || ""; + $("player-id").textContent = `Player ID: ${me.id}`; +} + +async function loadSuggestData() { + if (state.phase !== "Suggest") return; + state.mySuggestions = await api("/api/suggestions/mine"); + renderMySuggestions(); +} + +async function loadRevealData() { + if (state.phase === "Reveal" || state.phase === "Vote" || state.phase === "Results") { + state.allSuggestions = await api("/api/suggestions/all"); + renderAllSuggestions(); + } +} + +async function loadVoteData() { + if (state.phase !== "Vote") return; + state.myVotes = await api("/api/votes/mine"); + renderVotes(); +} + +async function loadResults() { + if (state.phase !== "Results") return; + state.results = await api("/api/results"); + renderResults(); +} + +function renderPhasePill() { + $("phase-pill").textContent = state.phase || "Loading…"; + document.querySelectorAll(".phase-view").forEach((el) => el.classList.add("hidden")); + const viewMap = { + Suggest: "suggest-view", + Reveal: "reveal-view", + Vote: "vote-view", + Results: "results-view" + }; + const id = viewMap[state.phase]; + if (id) $(id).classList.remove("hidden"); + $("phase-select").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 = `${s.name}${s.genre ? ` · ${s.genre}` : ""}
${s.description || ""}`; + list.appendChild(li); + }); +} + +function renderAllSuggestions() { + const list = $("all-suggestions"); + list.innerHTML = ""; + state.allSuggestions.forEach((s) => { + const li = document.createElement("li"); + li.innerHTML = `${s.name} by ${s.author || "Anonymous"}${s.genre ? ` · ${s.genre}` : ""}
${s.description || ""}`; + list.appendChild(li); + }); +} + +function renderVotes() { + const list = $("vote-list"); + list.innerHTML = ""; + const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); + state.allSuggestions.forEach((s) => { + const li = document.createElement("li"); + const current = votesMap[s.id] ?? 0; + li.innerHTML = ` +
+
+ ${s.name} by ${s.author || "Anonymous"} +
+
+ + ${current} +
+
`; + list.appendChild(li); + }); + list.querySelectorAll("input[type=range]").forEach((input) => { + input.addEventListener("input", (e) => { + const val = Number(e.target.value); + $("score-" + e.target.dataset.id).textContent = val; + }); + input.addEventListener("change", async (e) => { + const suggestionId = Number(e.target.dataset.id); + const score = Number(e.target.value); + try { + await api("/api/votes", { method: "POST", body: JSON.stringify({ suggestionId, score }) }); + toast("Saved vote"); + await loadVoteData(); + } catch (err) { + toast(err.message, true); + } + }); + }); +} + +function renderResults() { + const list = $("results-list"); + list.innerHTML = ""; + state.results.forEach((r) => { + const li = document.createElement("li"); + li.innerHTML = `${r.name} — ${r.total} pts (${r.count} votes, avg ${r.average.toFixed(1)})${r.author ? ` · ${r.author}` : ""}`; + list.appendChild(li); + }); +} + +function setupHandlers() { + $("save-name").addEventListener("click", async () => { + const name = $("name-input").value.trim(); + if (!name) return toast("Name required", true); + try { + const me = await api("/api/me/name", { method: "POST", body: JSON.stringify({ name }) }); + state.me = me; + toast("Saved name"); + } catch (err) { + toast(err.message, true); + } + }); + + $("suggest-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const form = e.target; + const data = Object.fromEntries(new FormData(form).entries()); + if (!data.name) return toast("Name required", true); + try { + await api("/api/suggestions", { method: "POST", body: JSON.stringify(data) }); + form.reset(); + toast("Suggestion added"); + await loadSuggestData(); + } catch (err) { + toast(err.message, true); + } + }); + + $("set-phase").addEventListener("click", async () => { + const phase = $("phase-select").value; + const adminKey = $("admin-key").value; + try { + await api("/api/admin/phase", { + method: "POST", + body: JSON.stringify({ phase }), + adminKey + }); + toast("Phase updated"); + state.phase = phase; + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } + }); + + $("reset").addEventListener("click", () => adminAction("/api/admin/reset", "Reset complete")); + $("factory-reset").addEventListener("click", () => adminAction("/api/admin/factory-reset", "Factory reset complete")); +} + +async function adminAction(path, successMessage) { + const adminKey = $("admin-key").value; + try { + await api(path, { method: "POST", adminKey }); + toast(successMessage); + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } +} + +async function refreshPhaseData() { + await loadState(); + await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]); +} + +async function main() { + setupHandlers(); + try { + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } + setInterval(refreshPhaseData, 4000); +} + +main(); diff --git a/wwwroot/index.html b/wwwroot/index.html index fb5c2cc..c6ae4dd 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -7,10 +7,87 @@ -
-

CoopGameChooser

-

MVP is on the way. API and UI will land here.

+
+
+

CoopGameChooser

+

Blind suggestions, blind votes, quick decision.

+
+
Loading…
+
+ +
+
+

Your Name

+ + +

+
+ +
+

Current Phase

+

Loading…

+

+
+ +
+ + + + + + + +
+ +
+

Admin

+ +
+ + +
+
+ + +
+
+ + + diff --git a/wwwroot/styles.css b/wwwroot/styles.css index 9fd4e90..e1be2bb 100644 --- a/wwwroot/styles.css +++ b/wwwroot/styles.css @@ -1,20 +1,118 @@ :root { font-family: "Segoe UI", system-ui, -apple-system, sans-serif; - background: #0f172a; - color: #e2e8f0; + background: radial-gradient(circle at 20% 20%, #1f2937, #0b1224); + color: #e5e7eb; } -.container { - max-width: 720px; - margin: 10vh auto; +body { + margin: 0; padding: 24px; - background: rgba(15, 23, 42, 0.7); - border-radius: 12px; - border: 1px solid #1e293b; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); } -h1 { - margin-top: 0; - letter-spacing: 0.5px; +.hero { + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(135deg, #111827, #0f172a); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 16px 20px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); } + +.subtitle { + margin: 4px 0 0; + color: #9ca3af; +} + +.phase-pill { + padding: 8px 12px; + background: #1d4ed8; + color: white; + border-radius: 999px; + font-weight: 600; + min-width: 96px; + text-align: center; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 16px; +} + +.card { + background: rgba(17, 24, 39, 0.9); + border: 1px solid #1f2937; + border-radius: 12px; + padding: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.card h2 { + margin-top: 0; + margin-bottom: 8px; +} + +.stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +.stack.horizontal { + flex-direction: row; + flex-wrap: wrap; +} + +input, textarea, select, button { + font: inherit; + border-radius: 8px; + border: 1px solid #374151; + background: #0f172a; + color: #e5e7eb; + padding: 10px 12px; + min-width: 0; +} + +textarea { min-height: 80px; resize: vertical; } + +button { + cursor: pointer; + background: #2563eb; + border-color: #1d4ed8; + font-weight: 600; +} + +button:hover { background: #1d4ed8; } + +button.danger { + background: #dc2626; + border-color: #b91c1c; +} + +.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; } + +.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; } +.score { font-weight: 700; } + +.hidden { display: none !important; } + +.toast { + position: fixed; + bottom: 16px; + right: 16px; + background: #2563eb; + color: white; + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0,0,0,0.35); + max-width: 320px; +} +.toast.error { background: #dc2626; }