diff --git a/.gitignore b/.gitignore index cb2dfcd..9b822a3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ App_Data/ # OS cruft Thumbs.db Desktop.ini +Properties/launchSettings.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5a938ce --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "useTabs": false +} diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index 16000a6..e719358 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -16,7 +16,7 @@ "applicationUrl": "http://localhost:5116", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "ADMIN_PASSWORD": "changeme" + "ADMIN_PASSWORD": "cookiedonut" } }, "https": { @@ -26,7 +26,7 @@ "applicationUrl": "https://localhost:7103;http://localhost:5116", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "ADMIN_PASSWORD": "changeme" + "ADMIN_PASSWORD": "cookiedonut" } }, "IIS Express": { diff --git a/wwwroot/app.js b/wwwroot/app.js index e237c59..290da77 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,272 +1,324 @@ import { api, adminApi } from "./js/api.js"; -import { t, setLanguage, getLanguage, initI18n, onLanguageChange } from "./js/i18n.js"; +import { + t, + setLanguage, + getLanguage, + initI18n, + onLanguageChange, +} from "./js/i18n.js"; initI18n(); const state = { - isAuthenticated: false, - authMode: "login", - me: null, - phase: null, - prevPhase: null, - counts: null, - mySuggestions: [], - allSuggestions: [], - allSuggestionsSig: null, - myVotes: [], - results: [], - votesRendered: false + isAuthenticated: false, + authMode: "login", + me: null, + phase: null, + prevPhase: null, + counts: null, + mySuggestions: [], + allSuggestions: [], + allSuggestionsSig: null, + myVotes: [], + results: [], + votesRendered: false, }; const $ = (id) => document.getElementById(id); const toastEl = $("toast"); function toast(msg, isError = false) { - if (!toastEl) return; - toastEl.textContent = msg; - toastEl.classList.remove("hidden"); - toastEl.classList.toggle("error", isError); - setTimeout(() => toastEl.classList.add("hidden"), 2000); + if (!toastEl) return; + toastEl.textContent = msg; + toastEl.classList.remove("hidden"); + toastEl.classList.toggle("error", isError); + setTimeout(() => toastEl.classList.add("hidden"), 2000); } const getSavedUsername = () => localStorage.getItem("last_username") || ""; const setSavedUsername = (name) => localStorage.setItem("last_username", name); function setAuthUI(isAuthed) { - const main = document.querySelector("main"); - const statusBar = document.querySelector(".status-bar"); - const authCard = $("auth-card"); - [main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed)); - if (authCard) authCard.classList.toggle("hidden", isAuthed); - const adminToggle = $("admin-toggle"); - if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin); - if (!isAuthed) { - const adminCard = $("admin-card"); - if (adminCard) adminCard.classList.add("hidden"); - const loginUser = $("login-username"); - const cachedUser = getSavedUsername(); - if (loginUser && cachedUser && !loginUser.dataset.userEditing && !loginUser.value) { - loginUser.value = cachedUser; + const main = document.querySelector("main"); + const statusBar = document.querySelector(".status-bar"); + const authCard = $("auth-card"); + [main, statusBar].forEach((el) => + el?.classList.toggle("hidden", !isAuthed), + ); + if (authCard) authCard.classList.toggle("hidden", isAuthed); + const adminToggle = $("admin-toggle"); + if (adminToggle) + adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin); + if (!isAuthed) { + const adminCard = $("admin-card"); + if (adminCard) adminCard.classList.add("hidden"); + const loginUser = $("login-username"); + const cachedUser = getSavedUsername(); + if ( + loginUser && + cachedUser && + !loginUser.dataset.userEditing && + !loginUser.value + ) { + loginUser.value = cachedUser; + } } - } } function setAuthMode(mode) { - state.authMode = mode; - document.querySelectorAll(".auth-form").forEach(form => { - form.classList.toggle("hidden", form.dataset.mode !== mode); - }); - const title = $("auth-title"); - const toggleBtn = $("auth-toggle"); - if (title) { - title.textContent = mode === "login" ? t("auth.loginHeading") : t("auth.registerHeading"); - } - if (toggleBtn) { - toggleBtn.textContent = mode === "login" ? t("auth.switchToRegister") : t("auth.switchToLogin"); - } + state.authMode = mode; + document.querySelectorAll(".auth-form").forEach((form) => { + form.classList.toggle("hidden", form.dataset.mode !== mode); + }); + const title = $("auth-title"); + const toggleBtn = $("auth-toggle"); + if (title) { + title.textContent = + mode === "login" + ? t("auth.loginHeading") + : t("auth.registerHeading"); + } + if (toggleBtn) { + toggleBtn.textContent = + mode === "login" + ? t("auth.switchToRegister") + : t("auth.switchToLogin"); + } } function clearUserState() { - state.me = null; - state.phase = null; - state.prevPhase = null; - state.counts = null; - state.mySuggestions = []; - state.allSuggestions = []; - state.myVotes = []; - state.results = []; - state.votesRendered = false; - const adminCard = $("admin-card"); - if (adminCard) adminCard.classList.add("hidden"); + state.me = null; + state.phase = null; + state.prevPhase = null; + state.counts = null; + state.mySuggestions = []; + state.allSuggestions = []; + state.myVotes = []; + state.results = []; + state.votesRendered = false; + const adminCard = $("admin-card"); + if (adminCard) adminCard.classList.add("hidden"); } function handleAuthError(err) { - if (err?.status === 401) { - clearUserState(); - state.isAuthenticated = false; - setAuthUI(false); - return true; - } - toast(err?.message || t("toast.unexpected"), true); - return false; + if (err?.status === 401) { + clearUserState(); + state.isAuthenticated = false; + setAuthUI(false); + return true; + } + toast(err?.message || t("toast.unexpected"), true); + return false; } async function loadState() { - const [me, stateData] = await Promise.all([api.me(), api.state()]); - state.isAuthenticated = true; - state.me = me; - state.prevPhase = state.phase; - state.phase = stateData.currentPhase; - state.counts = stateData; - if (state.prevPhase !== state.phase && state.phase === "Vote") { - state.votesRendered = false; - } - setAuthUI(true); - renderWelcome(); - renderPhasePill(); - renderCounts(); + const [me, stateData] = await Promise.all([api.me(), api.state()]); + state.isAuthenticated = true; + state.me = me; + state.prevPhase = state.phase; + state.phase = stateData.currentPhase; + state.counts = stateData; + if (state.prevPhase !== state.phase && state.phase === "Vote") { + state.votesRendered = false; + } + setAuthUI(true); + renderWelcome(); + renderPhasePill(); + renderCounts(); } async function loadSuggestData() { - if (state.phase !== "Suggest") return; - state.mySuggestions = await api.mySuggestions(); - renderMySuggestions(); + if (state.phase !== "Suggest") return; + state.mySuggestions = await api.mySuggestions(); + renderMySuggestions(); } async function loadRevealData() { - if (state.phase === "Reveal" || state.phase === "Vote" || state.phase === "Results") { - const latest = await api.allSuggestions(); - const latestSig = signatureSuggestions(latest); - const changed = latestSig !== state.allSuggestionsSig; - state.allSuggestions = latest; - state.allSuggestionsSig = latestSig; - renderAllSuggestions(); - renderPhaseTitles(); - if (state.phase === "Vote" && changed) { - state.votesRendered = false; + if ( + state.phase === "Reveal" || + state.phase === "Vote" || + state.phase === "Results" + ) { + const latest = await api.allSuggestions(); + const latestSig = signatureSuggestions(latest); + const changed = latestSig !== state.allSuggestionsSig; + state.allSuggestions = latest; + state.allSuggestionsSig = latestSig; + renderAllSuggestions(); + renderPhaseTitles(); + if (state.phase === "Vote" && changed) { + state.votesRendered = false; + } } - } } async function loadVoteData() { - if (state.phase !== "Vote") return; - const votes = await api.myVotes(); - state.myVotes = votes; - if (!state.votesRendered) { - renderVotes(); - state.votesRendered = true; - } else { - syncVoteScores(); - } + if (state.phase !== "Vote") return; + const votes = await api.myVotes(); + state.myVotes = votes; + if (!state.votesRendered) { + renderVotes(); + state.votesRendered = true; + } else { + syncVoteScores(); + } } async function loadResults() { - if (state.phase !== "Results") return; - state.results = await api.results(); - renderResults(); + if (state.phase !== "Results") return; + state.results = await api.results(); + renderResults(); } function renderPhasePill() { - const phaseKey = typeof state.phase === "string" ? state.phase.toLowerCase() : null; - $("phase-pill").textContent = phaseKey ? "" : t("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"); - const phaseSelect = $("phase-select"); - if (phaseSelect && !phaseSelect.dataset.userEditing) { - phaseSelect.value = state.phase || "Suggest"; - } + const phaseKey = + typeof state.phase === "string" ? state.phase.toLowerCase() : null; + $("phase-pill").textContent = phaseKey ? "" : t("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"); + const phaseSelect = $("phase-select"); + if (phaseSelect && !phaseSelect.dataset.userEditing) { + phaseSelect.value = state.phase || "Suggest"; + } } function renderCounts() { - if (!state.counts) return; - $("counts").textContent = t("counts.format", { - players: state.counts.players, - suggestions: state.counts.suggestions, - votes: state.counts.votes - }); + if (!state.counts) return; + $("counts").textContent = t("counts.format", { + players: state.counts.players, + suggestions: state.counts.suggestions, + votes: state.counts.votes, + }); } function renderWelcome() { - const el = $("welcome-text"); - if (!el) return; - const name = state.me?.displayName?.trim() || state.me?.username || t("auth.defaultName"); - el.textContent = t("auth.welcome", { name }); + const el = $("welcome-text"); + if (!el) return; + const name = + state.me?.displayName?.trim() || + state.me?.username || + t("auth.defaultName"); + el.textContent = t("auth.welcome", { name }); } function renderMySuggestions() { - const wrap = $("my-suggestions"); - if (!wrap) return; - wrap.innerHTML = ""; - const allowEdit = state.phase === "Suggest" || state.me?.isAdmin; - state.mySuggestions.forEach((s) => wrap.appendChild(buildCard(s, { showAuthor: false, allowDelete: true, allowEdit }))); + const wrap = $("my-suggestions"); + if (!wrap) return; + wrap.innerHTML = ""; + const allowEdit = state.phase === "Suggest" || state.me?.isAdmin; + state.mySuggestions.forEach((s) => + wrap.appendChild( + buildCard(s, { showAuthor: false, allowDelete: true, allowEdit }), + ), + ); } function renderAllSuggestions() { - const list = $("all-suggestions"); - if (!list) return; - list.innerHTML = ""; - const allowEdit = !!state.me?.isAdmin; - const allowDelete = !!state.me?.isAdmin && (state.phase === "Reveal" || state.phase === "Suggest"); - state.allSuggestions.forEach((s) => list.appendChild(buildCard(s, { showAuthor: true, allowEdit, allowDelete }))); - renderPhaseTitles(); + const list = $("all-suggestions"); + if (!list) return; + list.innerHTML = ""; + const allowEdit = !!state.me?.isAdmin; + const allowDelete = + !!state.me?.isAdmin && + (state.phase === "Reveal" || state.phase === "Suggest"); + state.allSuggestions.forEach((s) => + list.appendChild( + buildCard(s, { showAuthor: true, allowEdit, allowDelete }), + ), + ); + renderPhaseTitles(); } function renderVotes() { - const list = $("vote-list"); - if (!list) return; - list.innerHTML = ""; - const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); - state.allSuggestions.forEach((s) => { - const li = buildCard(s, { showAuthor: true, allowEdit: !!state.me?.isAdmin }); - const hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id); - const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote - const displayScore = hasVote ? current : "—"; - const displayEmoji = hasVote ? scoreToEmoji(current) : neutralEmoji(); - const footer = document.createElement("div"); - footer.className = "vote-controls"; - footer.innerHTML = ` + const list = $("vote-list"); + if (!list) return; + list.innerHTML = ""; + const votesMap = Object.fromEntries( + state.myVotes.map((v) => [v.suggestionId, v.score]), + ); + state.allSuggestions.forEach((s) => { + const li = buildCard(s, { + showAuthor: true, + allowEdit: !!state.me?.isAdmin, + }); + const hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id); + const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote + const displayScore = hasVote ? current : "—"; + const displayEmoji = hasVote ? scoreToEmoji(current) : neutralEmoji(); + const footer = document.createElement("div"); + footer.className = "vote-controls"; + footer.innerHTML = ` ${displayScore} ${displayEmoji}`; - li.querySelector(".card-body").appendChild(footer); - 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; - const emojiEl = $("emoji-" + e.target.dataset.id); - if (emojiEl) emojiEl.textContent = scoreToEmoji(val); + li.querySelector(".card-body").appendChild(footer); + list.appendChild(li); }); - input.addEventListener("change", async (e) => { - const suggestionId = Number(e.target.dataset.id); - const score = Number(e.target.value); - try { - await api.vote(suggestionId, score); - toast(t("vote.saved")); - await loadVoteData(); - } catch (err) { - toast(err.message, true); - } + list.querySelectorAll("input[type=range]").forEach((input) => { + input.addEventListener("input", (e) => { + const val = Number(e.target.value); + $("score-" + e.target.dataset.id).textContent = val; + const emojiEl = $("emoji-" + e.target.dataset.id); + if (emojiEl) emojiEl.textContent = scoreToEmoji(val); + }); + input.addEventListener("change", async (e) => { + const suggestionId = Number(e.target.dataset.id); + const score = Number(e.target.value); + try { + await api.vote(suggestionId, score); + toast(t("vote.saved")); + await loadVoteData(); + } catch (err) { + toast(err.message, true); + } + }); }); - }); } function syncVoteScores() { - const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); - Object.entries(votesMap).forEach(([id, score]) => { - const slider = document.querySelector(`input[type=range][data-id="${id}"]`); - const scoreLabel = $("score-" + id); - const emoji = $("emoji-" + id); - if (slider && score != null) { - slider.value = score; - if (scoreLabel) scoreLabel.textContent = score; - if (emoji) emoji.textContent = scoreToEmoji(score); - } - }); - document.querySelectorAll("input[type=range][data-id]").forEach((slider) => { - const id = slider.dataset.id; - if (Object.prototype.hasOwnProperty.call(votesMap, Number(id))) return; - const scoreLabel = $("score-" + id); - const emoji = $("emoji-" + id); - if (scoreLabel) scoreLabel.textContent = "—"; - if (emoji) emoji.textContent = neutralEmoji(); - }); + const votesMap = Object.fromEntries( + state.myVotes.map((v) => [v.suggestionId, v.score]), + ); + Object.entries(votesMap).forEach(([id, score]) => { + const slider = document.querySelector( + `input[type=range][data-id="${id}"]`, + ); + const scoreLabel = $("score-" + id); + const emoji = $("emoji-" + id); + if (slider && score != null) { + slider.value = score; + if (scoreLabel) scoreLabel.textContent = score; + if (emoji) emoji.textContent = scoreToEmoji(score); + } + }); + document + .querySelectorAll("input[type=range][data-id]") + .forEach((slider) => { + const id = slider.dataset.id; + if (Object.prototype.hasOwnProperty.call(votesMap, Number(id))) + return; + const scoreLabel = $("score-" + id); + const emoji = $("emoji-" + id); + if (scoreLabel) scoreLabel.textContent = "—"; + if (emoji) emoji.textContent = neutralEmoji(); + }); } function renderResults() { - const container = $("results-list"); - container.innerHTML = ""; - const table = document.createElement("table"); - table.className = "results-table"; - table.innerHTML = ` + const container = $("results-list"); + container.innerHTML = ""; + const table = document.createElement("table"); + table.className = "results-table"; + table.innerHTML = ` ${t("results.rank")} @@ -280,16 +332,16 @@ function renderResults() { `; - const tbody = table.querySelector("tbody"); - state.results.forEach((r, idx) => { - const row = document.createElement("tr"); - row.innerHTML = ` + const tbody = table.querySelector("tbody"); + state.results.forEach((r, idx) => { + const row = document.createElement("tr"); + row.innerHTML = ` ${idx + 1} - ${r.screenshotUrl ? `${r.name}` : ''} + ${r.screenshotUrl ? `${r.name}` : ""}
${r.name}
- ${r.genre ? `
${r.genre}
` : ''} + ${r.genre ? `
${r.genre}
` : ""}
${r.author ?? "—"} @@ -297,235 +349,291 @@ function renderResults() { ${r.average.toFixed(1)} ${r.total} - ${r.gameUrl ? `${t("results.link.site")}
` : ''} - ${r.youtubeUrl ? `${t("results.link.youtube")}` : ''} + ${r.gameUrl ? `${t("results.link.site")}
` : ""} + ${r.youtubeUrl ? `${t("results.link.youtube")}` : ""} `; - tbody.appendChild(row); - }); - const frame = document.createElement("div"); - frame.className = "results-frame"; - frame.appendChild(table); - container.appendChild(frame); - container.querySelectorAll(".clickable-thumb").forEach(img => { - img.addEventListener("click", () => openLightbox(img.src, img.alt)); - }); + tbody.appendChild(row); + }); + const frame = document.createElement("div"); + frame.className = "results-frame"; + frame.appendChild(table); + container.appendChild(frame); + container.querySelectorAll(".clickable-thumb").forEach((img) => { + img.addEventListener("click", () => openLightbox(img.src, img.alt)); + }); } function renderPhaseTitles() { - const revealTitle = $("reveal-title"); - const voteTitle = $("vote-title"); - const totalGames = state.allSuggestions?.length ?? 0; - if (revealTitle) { - revealTitle.textContent = totalGames > 0 ? t("section.allSuggestions.count", { count: totalGames }) : t("section.allSuggestions"); - } - if (voteTitle) { - voteTitle.textContent = totalGames > 0 ? t("section.vote.count", { count: totalGames }) : t("section.vote"); - } + const revealTitle = $("reveal-title"); + const voteTitle = $("vote-title"); + const totalGames = state.allSuggestions?.length ?? 0; + if (revealTitle) { + revealTitle.textContent = + totalGames > 0 + ? t("section.allSuggestions.count", { count: totalGames }) + : t("section.allSuggestions"); + } + if (voteTitle) { + voteTitle.textContent = + totalGames > 0 + ? t("section.vote.count", { count: totalGames }) + : t("section.vote"); + } } function setupHandlers() { - const toggleAuth = $("auth-toggle"); - if (toggleAuth) { - toggleAuth.addEventListener("click", (e) => { - e.preventDefault(); - setAuthMode(state.authMode === "login" ? "register" : "login"); - }); - } - setAuthMode(state.authMode); + const toggleAuth = $("auth-toggle"); + if (toggleAuth) { + toggleAuth.addEventListener("click", (e) => { + e.preventDefault(); + setAuthMode(state.authMode === "login" ? "register" : "login"); + }); + } + setAuthMode(state.authMode); - const loginUser = $("login-username"); - if (loginUser) { - const markEditing = () => { loginUser.dataset.userEditing = "1"; }; - ["focus", "input", "keydown"].forEach(evt => loginUser.addEventListener(evt, markEditing)); - loginUser.addEventListener("blur", () => { delete loginUser.dataset.userEditing; }); - } + const loginUser = $("login-username"); + if (loginUser) { + const markEditing = () => { + loginUser.dataset.userEditing = "1"; + }; + ["focus", "input", "keydown"].forEach((evt) => + loginUser.addEventListener(evt, markEditing), + ); + loginUser.addEventListener("blur", () => { + delete loginUser.dataset.userEditing; + }); + } - const langSelects = Array.from(document.querySelectorAll(".lang-select")); - const syncLanguageSelects = () => langSelects.forEach(sel => sel.value = getLanguage()); - syncLanguageSelects(); - langSelects.forEach(sel => sel.addEventListener("change", () => setLanguage(sel.value))); - - onLanguageChange(() => { + const langSelects = Array.from(document.querySelectorAll(".lang-select")); + const syncLanguageSelects = () => + langSelects.forEach((sel) => (sel.value = getLanguage())); syncLanguageSelects(); - renderWelcome(); - renderPhasePill(); - renderCounts(); - renderPhaseTitles(); - renderMySuggestions(); - renderAllSuggestions(); - if (state.phase === "Vote") { - renderVotes(); - state.votesRendered = true; - syncVoteScores(); - } - if (state.phase === "Results") { - renderResults(); - } - }); + langSelects.forEach((sel) => + sel.addEventListener("change", () => setLanguage(sel.value)), + ); - const loginForm = $("login-form"); - if (loginForm) { - loginForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const username = $("login-username").value.trim(); - const password = $("login-password").value; - if (username.length > 24) return toast("Username must be 24 characters or fewer.", true); - if (!username || !password) return toast(t("auth.needCredentials"), true); - try { - await api.login({ username, password }); - setSavedUsername(username); - state.isAuthenticated = true; - setAuthUI(true); - await refreshPhaseData(); - toast(t("toast.loggedIn")); - } catch (err) { - if (err?.status === 401) return toast(t("auth.invalidCredentials"), true); - if (handleAuthError(err)) return; - } + onLanguageChange(() => { + syncLanguageSelects(); + renderWelcome(); + renderPhasePill(); + renderCounts(); + renderPhaseTitles(); + renderMySuggestions(); + renderAllSuggestions(); + if (state.phase === "Vote") { + renderVotes(); + state.votesRendered = true; + syncVoteScores(); + } + if (state.phase === "Results") { + renderResults(); + } }); - } - const registerForm = $("register-form"); - if (registerForm) { - registerForm.addEventListener("submit", async (e) => { - e.preventDefault(); - const username = $("register-username").value.trim(); - const password = $("register-password").value; - const displayName = $("register-displayName").value.trim(); - const adminKey = $("register-adminkey").value.trim(); - if (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true); - if (username.length > 24) return toast("Username must be 24 characters or fewer.", true); - if (displayName.length > 16) return toast("Display name must be 16 characters or fewer.", true); - if (!username || !password) return toast(t("auth.needCredentials"), true); - try { - await api.register({ username, password, displayName, adminKey }); - setSavedUsername(username); - state.isAuthenticated = true; - setAuthUI(true); - await refreshPhaseData(); - toast(t("toast.registered")); - } catch (err) { - if (handleAuthError(err)) return; - toast(err.message, true); - } + const loginForm = $("login-form"); + if (loginForm) { + loginForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = $("login-username").value.trim(); + const password = $("login-password").value; + if (username.length > 24) + return toast("Username must be 24 characters or fewer.", true); + if (!username || !password) + return toast(t("auth.needCredentials"), true); + try { + await api.login({ username, password }); + setSavedUsername(username); + state.isAuthenticated = true; + setAuthUI(true); + await refreshPhaseData(); + toast(t("toast.loggedIn")); + } catch (err) { + if (err?.status === 401) + return toast(t("auth.invalidCredentials"), true); + if (handleAuthError(err)) return; + } + }); + } + + const registerForm = $("register-form"); + if (registerForm) { + registerForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const username = $("register-username").value.trim(); + const password = $("register-password").value; + const displayName = $("register-displayName").value.trim(); + const adminKey = $("register-adminkey").value.trim(); + if (!displayName) + return toast( + t("toast.displayNameRequired") || + "Display name is required.", + true, + ); + if (username.length > 24) + return toast("Username must be 24 characters or fewer.", true); + if (displayName.length > 16) + return toast( + "Display name must be 16 characters or fewer.", + true, + ); + if (!username || !password) + return toast(t("auth.needCredentials"), true); + try { + await api.register({ + username, + password, + displayName, + adminKey, + }); + setSavedUsername(username); + state.isAuthenticated = true; + setAuthUI(true); + await refreshPhaseData(); + toast(t("toast.registered")); + } catch (err) { + if (handleAuthError(err)) return; + toast(err.message, true); + } + }); + } + + $("suggest-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const form = e.target; + const data = normalizeSuggestionForm(new FormData(form)); + if (!data.name) return toast(t("toast.nameRequired"), true); + if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { + return toast(t("toast.invalidImageUrl"), true); + } + try { + await api.createSuggestion(data); + form.reset(); + toast(t("toast.suggestionAdded")); + await loadSuggestData(); + } catch (err) { + toast(err.message, true); + } }); - } - $("suggest-form").addEventListener("submit", async (e) => { - e.preventDefault(); - const form = e.target; - const data = normalizeSuggestionForm(new FormData(form)); - if (!data.name) return toast(t("toast.nameRequired"), true); - if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { - return toast(t("toast.invalidImageUrl"), true); - } - try { - await api.createSuggestion(data); - form.reset(); - toast(t("toast.suggestionAdded")); - await loadSuggestData(); - } catch (err) { - toast(err.message, true); - } - }); - - $("set-phase").addEventListener("click", async () => { - const phase = $("phase-select").value; - try { - await adminApi.setPhase(phase); - toast(t("admin.phaseUpdated")); - state.prevPhase = state.phase; - state.phase = phase; - state.votesRendered = false; - renderPhasePill(); - $("phase-select").dataset.userEditing = ""; - await refreshPhaseData(); - } catch (err) { - toast(err.message, true); - } - }); - - const phaseSelect = $("phase-select"); - ["focus", "input", "click"].forEach(evt => { - phaseSelect.addEventListener(evt, () => { phaseSelect.dataset.userEditing = "1"; }); - }); - phaseSelect.addEventListener("blur", () => { phaseSelect.dataset.userEditing = ""; }); - - $("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone"))); - $("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone"))); - - const logoutBtn = $("logout"); - if (logoutBtn) { - logoutBtn.addEventListener("click", async (e) => { - e.preventDefault(); - const lastUser = state.me?.username; - try { - await api.logout(); - } catch (err) { - toast(err.message, true); - } - clearUserState(); - state.isAuthenticated = false; - setAuthUI(false); - if (lastUser) { - setSavedUsername(lastUser); - const loginUser = $("login-username"); - if (loginUser) loginUser.value = lastUser; - const loginPass = $("login-password"); - if (loginPass) loginPass.value = ""; - } + $("set-phase").addEventListener("click", async () => { + const phase = $("phase-select").value; + try { + await adminApi.setPhase(phase); + toast(t("admin.phaseUpdated")); + state.prevPhase = state.phase; + state.phase = phase; + state.votesRendered = false; + renderPhasePill(); + $("phase-select").dataset.userEditing = ""; + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } }); - } - const adminToggle = $("admin-toggle"); - const adminCard = $("admin-card"); - const adminClose = $("admin-close"); - if (adminToggle && adminCard && adminClose) { - const togglePanel = (show) => adminCard.classList.toggle("hidden", !show); - adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden"))); - adminClose.addEventListener("click", () => togglePanel(false)); - } + const phaseSelect = $("phase-select"); + ["focus", "input", "click"].forEach((evt) => { + phaseSelect.addEventListener(evt, () => { + phaseSelect.dataset.userEditing = "1"; + }); + }); + phaseSelect.addEventListener("blur", () => { + phaseSelect.dataset.userEditing = ""; + }); + + $("reset").addEventListener("click", () => + adminAction(adminApi.reset, t("admin.resetDone")), + ); + $("factory-reset").addEventListener("click", () => + adminAction(adminApi.factoryReset, t("admin.factoryResetDone")), + ); + + const logoutBtn = $("logout"); + if (logoutBtn) { + logoutBtn.addEventListener("click", async (e) => { + e.preventDefault(); + const lastUser = state.me?.username; + try { + await api.logout(); + } catch (err) { + toast(err.message, true); + } + clearUserState(); + state.isAuthenticated = false; + setAuthUI(false); + if (lastUser) { + setSavedUsername(lastUser); + const loginUser = $("login-username"); + if (loginUser) loginUser.value = lastUser; + const loginPass = $("login-password"); + if (loginPass) loginPass.value = ""; + } + }); + } + + const adminToggle = $("admin-toggle"); + const adminCard = $("admin-card"); + const adminClose = $("admin-close"); + if (adminToggle && adminCard && adminClose) { + const togglePanel = (show) => + adminCard.classList.toggle("hidden", !show); + adminToggle.addEventListener("click", () => + togglePanel(adminCard.classList.contains("hidden")), + ); + adminClose.addEventListener("click", () => togglePanel(false)); + } } async function adminAction(fn, successMessage) { - try { - await fn(); - toast(successMessage); - await refreshPhaseData(); - } catch (err) { - toast(err.message, true); - } + try { + await fn(); + toast(successMessage); + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } } async function refreshPhaseData() { - try { - await loadState(); - await Promise.all([loadSuggestData(), loadRevealData(), loadResults()]); - if (state.phase === "Vote") { - if (!state.votesRendered) await loadVoteData(); - } else { - state.votesRendered = false; - await loadVoteData(); + try { + await loadState(); + await Promise.all([loadSuggestData(), loadRevealData(), loadResults()]); + if (state.phase === "Vote") { + if (!state.votesRendered) await loadVoteData(); + } else { + state.votesRendered = false; + await loadVoteData(); + } + } catch (err) { + if (handleAuthError(err)) return; + throw err; } - } catch (err) { - if (handleAuthError(err)) return; - throw err; - } } -function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = false }) { - const card = document.createElement("article"); - card.className = "game-card"; - const hasImage = !!s.screenshotUrl; - const visual = hasImage - ? `` - : `
`; - const hasPlayers = (s.minPlayers || s.maxPlayers); - const players = hasPlayers ? `${t("card.players", { min: s.minPlayers ?? "?", max: s.maxPlayers ?? "?" })}` : ""; - const genreAndPlayers = s.genre ? (hasPlayers ? `${s.genre} • ${players}` : s.genre) : (hasPlayers ? players : undefined); - const hasExtraInfo = genreAndPlayers || s.gameUrl || s.youtubeUrl; - card.innerHTML = ` +function buildCard( + s, + { showAuthor = false, allowDelete = false, allowEdit = false }, +) { + const card = document.createElement("article"); + card.className = "game-card"; + const hasImage = !!s.screenshotUrl; + const visual = hasImage + ? `` + : `
`; + const hasPlayers = s.minPlayers || s.maxPlayers; + const players = hasPlayers + ? `${t("card.players", { min: s.minPlayers ?? "?", max: s.maxPlayers ?? "?" })}` + : ""; + const genreAndPlayers = s.genre + ? hasPlayers + ? `${s.genre} • ${players}` + : s.genre + : hasPlayers + ? players + : undefined; + const hasExtraInfo = genreAndPlayers || s.gameUrl || s.youtubeUrl; + card.innerHTML = ` ${visual}
@@ -544,34 +652,36 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal ${s.description ? `

${s.description}

` : ""}
`; - if (hasImage) { - const btn = card.querySelector(".card-visual"); - setupCardVisualHover(btn, s.screenshotUrl); - btn.addEventListener("click", () => openLightbox(s.screenshotUrl, s.name)); - } - if (allowEdit) { - const editBtn = card.querySelector("[data-edit]"); - editBtn?.addEventListener("click", () => openEditModal(s)); - } - if (allowDelete) { - const del = card.querySelector("[data-delete]"); - del.addEventListener("click", async () => { - try { - await api.deleteSuggestion(s.id); - toast(t("toast.suggestionDeleted")); - await loadSuggestData(); - } catch (err) { - toast(err.message, true); - } - }); - } - return card; + if (hasImage) { + const btn = card.querySelector(".card-visual"); + setupCardVisualHover(btn, s.screenshotUrl); + btn.addEventListener("click", () => + openLightbox(s.screenshotUrl, s.name), + ); + } + if (allowEdit) { + const editBtn = card.querySelector("[data-edit]"); + editBtn?.addEventListener("click", () => openEditModal(s)); + } + if (allowDelete) { + const del = card.querySelector("[data-delete]"); + del.addEventListener("click", async () => { + try { + await api.deleteSuggestion(s.id); + toast(t("toast.suggestionDeleted")); + await loadSuggestData(); + } catch (err) { + toast(err.message, true); + } + }); + } + return card; } function openEditModal(s) { - const overlay = document.createElement("div"); - overlay.className = "edit-modal"; - overlay.innerHTML = ` + const overlay = document.createElement("div"); + overlay.className = "edit-modal"; + overlay.innerHTML = `

${t("modal.editTitle")}

@@ -625,174 +735,183 @@ function openEditModal(s) {
`; - const close = () => overlay.remove(); - overlay.addEventListener("click", (e) => { - if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close(); - }); + const close = () => overlay.remove(); + overlay.addEventListener("click", (e) => { + if ( + e.target.classList.contains("edit-modal") || + e.target.classList.contains("lightbox-close") + ) + close(); + }); - const cancelBtn = overlay.querySelector("#edit-cancel"); - cancelBtn?.addEventListener("click", close); + const cancelBtn = overlay.querySelector("#edit-cancel"); + cancelBtn?.addEventListener("click", close); - const form = overlay.querySelector("#edit-form"); - form?.addEventListener("submit", async (e) => { - e.preventDefault(); - const data = normalizeSuggestionForm(new FormData(form)); - if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { - return toast(t("toast.invalidImageUrl"), true); - } - if (!data.name?.trim()) return toast(t("toast.nameRequired"), true); - try { - await api.updateSuggestion(s.id, data); - toast(t("toast.savedChanges")); - close(); - await refreshPhaseData(); - } catch (err) { - if (handleAuthError(err)) return; - toast(err.message, true); - } - }); + const form = overlay.querySelector("#edit-form"); + form?.addEventListener("submit", async (e) => { + e.preventDefault(); + const data = normalizeSuggestionForm(new FormData(form)); + if (data.screenshotUrl && !isValidImageUrl(data.screenshotUrl)) { + return toast(t("toast.invalidImageUrl"), true); + } + if (!data.name?.trim()) return toast(t("toast.nameRequired"), true); + try { + await api.updateSuggestion(s.id, data); + toast(t("toast.savedChanges")); + close(); + await refreshPhaseData(); + } catch (err) { + if (handleAuthError(err)) return; + toast(err.message, true); + } + }); - document.body.appendChild(overlay); + document.body.appendChild(overlay); } function openLightbox(url, title) { - const overlay = document.createElement("div"); - overlay.className = "lightbox"; - overlay.innerHTML = ` + const overlay = document.createElement("div"); + overlay.className = "lightbox"; + overlay.innerHTML = ` `; - overlay.addEventListener("click", (e) => { - if (e.target.classList.contains("lightbox") || e.target.classList.contains("lightbox-close")) { - overlay.remove(); - } - }); - document.body.appendChild(overlay); + overlay.addEventListener("click", (e) => { + if ( + e.target.classList.contains("lightbox") || + e.target.classList.contains("lightbox-close") + ) { + overlay.remove(); + } + }); + document.body.appendChild(overlay); } function setupCardVisualHover(el, url) { - if (!el || !url) return; - const img = new Image(); - let naturalW = 0; - let naturalH = 0; - let loaded = false; - img.src = url; - img.onload = () => { - naturalW = img.naturalWidth; - naturalH = img.naturalHeight; - loaded = true; - }; + if (!el || !url) return; + const img = new Image(); + let naturalW = 0; + let naturalH = 0; + let loaded = false; + img.src = url; + img.onload = () => { + naturalW = img.naturalWidth; + naturalH = img.naturalHeight; + loaded = true; + }; - const reset = () => { - el.classList.remove("hovering"); - el.style.backgroundSize = ""; - el.style.backgroundPosition = ""; - el.style.backgroundRepeat = ""; - }; + const reset = () => { + el.classList.remove("hovering"); + el.style.backgroundSize = ""; + el.style.backgroundPosition = ""; + el.style.backgroundRepeat = ""; + }; - el.addEventListener("mouseenter", () => { - el.classList.add("hovering"); - el.style.backgroundSize = "auto"; - el.style.backgroundRepeat = "no-repeat"; - el.style.backgroundPosition = "center"; - }); + el.addEventListener("mouseenter", () => { + el.classList.add("hovering"); + el.style.backgroundSize = "auto"; + el.style.backgroundRepeat = "no-repeat"; + el.style.backgroundPosition = "center"; + }); - el.addEventListener("mousemove", (e) => { - if (!loaded) return; - const rect = el.getBoundingClientRect(); - const overW = naturalW - rect.width; - const overH = naturalH - rect.height; - if (overW <= 0 && overH <= 0) { - el.style.backgroundPosition = "center"; - return; - } - const xRatio = (e.clientX - rect.left) / rect.width; - const yRatio = (e.clientY - rect.top) / rect.height; - const xPercent = overW > 0 ? xRatio * 100 : 50; - const yPercent = overH > 0 ? yRatio * 100 : 50; - el.style.backgroundPosition = `${xPercent}% ${yPercent}%`; - }); + el.addEventListener("mousemove", (e) => { + if (!loaded) return; + const rect = el.getBoundingClientRect(); + const overW = naturalW - rect.width; + const overH = naturalH - rect.height; + if (overW <= 0 && overH <= 0) { + el.style.backgroundPosition = "center"; + return; + } + const xRatio = (e.clientX - rect.left) / rect.width; + const yRatio = (e.clientY - rect.top) / rect.height; + const xPercent = overW > 0 ? xRatio * 100 : 50; + const yPercent = overH > 0 ? yRatio * 100 : 50; + el.style.backgroundPosition = `${xPercent}% ${yPercent}%`; + }); - ["mouseleave", "blur"].forEach(evt => el.addEventListener(evt, reset)); + ["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, reset)); } async function main() { - setupHandlers(); - try { - await refreshPhaseData(); - } catch (err) { - toast(err.message, true); - } - setInterval(() => { - refreshPhaseData().catch(err => { - if (!handleAuthError(err)) toast(err.message, true); - }); - }, 4000); + setupHandlers(); + try { + await refreshPhaseData(); + } catch (err) { + toast(err.message, true); + } + setInterval(() => { + refreshPhaseData().catch((err) => { + if (!handleAuthError(err)) toast(err.message, true); + }); + }, 4000); } main(); function isValidImageUrl(url) { - if (!url) return true; - try { - const u = new URL(url); - const allowed = ["http:", "https:"]; - if (!allowed.includes(u.protocol)) return false; - const path = u.pathname.toLowerCase(); - return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].some(ext => path.endsWith(ext)); - } catch { - return false; - } + if (!url) return true; + try { + const u = new URL(url); + const allowed = ["http:", "https:"]; + if (!allowed.includes(u.protocol)) return false; + const path = u.pathname.toLowerCase(); + return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"].some((ext) => + path.endsWith(ext), + ); + } catch { + return false; + } } function normalizeSuggestionForm(formData) { - const obj = Object.fromEntries(formData.entries()); - const parseNum = (v) => { - if (v === undefined || v === null || v === "") return null; - const n = Number(v); - return Number.isFinite(n) ? n : null; - }; - return { - name: obj.name?.trim(), - genre: obj.genre?.trim() || null, - description: obj.description?.trim() || null, - screenshotUrl: obj.screenshotUrl?.trim() || null, - youtubeUrl: obj.youtubeUrl?.trim() || null, - gameUrl: obj.gameUrl?.trim() || null, - minPlayers: parseNum(obj.minPlayers), - maxPlayers: parseNum(obj.maxPlayers), - }; + const obj = Object.fromEntries(formData.entries()); + const parseNum = (v) => { + if (v === undefined || v === null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + return { + name: obj.name?.trim(), + genre: obj.genre?.trim() || null, + description: obj.description?.trim() || null, + screenshotUrl: obj.screenshotUrl?.trim() || null, + youtubeUrl: obj.youtubeUrl?.trim() || null, + gameUrl: obj.gameUrl?.trim() || null, + minPlayers: parseNum(obj.minPlayers), + maxPlayers: parseNum(obj.maxPlayers), + }; } function scoreToEmoji(score) { - if (score == null || Number.isNaN(score)) return neutralEmoji(); - if (score < 1) return "😡"; - if (score <= 3) return "😠"; - if (score <= 6) return "😐"; - if (score <= 8) return "🙂"; - if (score <= 9) return "😃"; - return "🤩"; + if (score == null || Number.isNaN(score)) return neutralEmoji(); + if (score < 1) return "😡"; + if (score <= 3) return "😠"; + if (score <= 6) return "😐"; + if (score <= 8) return "🙂"; + if (score <= 9) return "😃"; + return "🤩"; } function neutralEmoji() { - return "😐"; + return "⬅️"; } function signatureSuggestions(list) { - return JSON.stringify( - list.map((s) => [ - s.id, - s.name, - s.genre, - s.description, - s.screenshotUrl, - s.youtubeUrl, - s.gameUrl, - s.minPlayers, - s.maxPlayers - ]) - ); + return JSON.stringify( + list.map((s) => [ + s.id, + s.name, + s.genre, + s.description, + s.screenshotUrl, + s.youtubeUrl, + s.gameUrl, + s.minPlayers, + s.maxPlayers, + ]), + ); } diff --git a/wwwroot/index.html b/wwwroot/index.html index 5d331ae..780add1 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -58,7 +58,7 @@ diff --git a/wwwroot/styles.css b/wwwroot/styles.css index 42f5c45..9a14df8 100644 --- a/wwwroot/styles.css +++ b/wwwroot/styles.css @@ -1,372 +1,606 @@ :root { - font-family: "Baloo 2", "Nunito", "Segoe UI", system-ui, -apple-system, sans-serif; - background: #f6e9d6; - color: #2c1c0d; + font-family: + "Baloo 2", + "Nunito", + "Segoe UI", + system-ui, + -apple-system, + sans-serif; + background: #f6e9d6; + color: #2c1c0d; } *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } .page { - margin: 0; - padding: 20px; - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - min-height: 100vh; - background: url("background.png") center/cover no-repeat fixed, #f6e9d6; + margin: 0; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + min-height: 100vh; + background: + url("background.png") center/cover no-repeat fixed, + #f6e9d6; } -.lang-field { margin-top: 8px; } -.lang-field select { min-width: 160px; } -.compact-select { min-width: 120px; } +.lang-field { + margin-top: 8px; +} +.lang-field select { + min-width: 160px; +} +.compact-select { + min-width: 120px; +} .status-bar { - display: flex; - width: 100%; - justify-content: space-between; - gap: 30px; - background: rgba(255, 255, 255, 0.9); - border: 1px solid #e3d4bd; - border-radius: 12px; - box-shadow: 0 10px 30px rgba(0,0,0,0.12); - padding: 10px 14px; + display: flex; + width: 100%; + justify-content: space-between; + gap: 30px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid #e3d4bd; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); + padding: 10px 14px; +} +.status-left, +.status-center, +.status-right { + display: flex; + align-items: center; + gap: 10px; +} +.inline-link { + font-size: 14px; + margin-left: 5px; } -.status-left, .status-center, .status-right { display: flex; align-items: center; gap: 10px; } -.inline-link { font-size: 14px; margin-left: 5px; } .logo-mark { - height: 65px; - margin: -10px; - width: auto; + height: 65px; + margin: -10px; + width: auto; } .counts { - color: #5f513b; - font-size: 13px; + color: #5f513b; + font-size: 13px; } -.phase-bar { display: none; } +.phase-bar { + display: none; +} .grid { - display: grid; - grid-template-columns: 1fr; - gap: 16px; - width: 100%; - max-width: 1280px; + display: grid; + grid-template-columns: 1fr; + gap: 16px; + width: 100%; + max-width: 1280px; } .suggest-grid { - display: grid; - grid-template-columns: 1.1fr 1fr; - gap: 16px; - align-items: start; + display: grid; + grid-template-columns: 1.1fr 1fr; + gap: 16px; + align-items: start; } .suggest-grid .card-grid { - grid-template-columns: 1fr; + grid-template-columns: 1fr; } .phase-header { - background: rgba(255, 255, 255, 0.9); - border: 1px solid #e3d4bd; - border-radius: 12px; - padding: 12px 16px; - box-shadow: 0 10px 24px rgba(0,0,0,0.1); - margin-bottom: 12px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid #e3d4bd; + border-radius: 12px; + padding: 12px 16px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.1); + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.phase-header h2 { + margin: 0; +} +.phase-header .hint { + margin: 0; } -.phase-header h2 { margin: 0; } -.phase-header .hint { margin: 0; } .card { - background: rgba(255, 255, 255, 0.9); - border: 1px solid #e3d4bd; - border-radius: 14px; - padding: 16px; - box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); - margin-bottom: 16px; + background: rgba(255, 255, 255, 0.9); + border: 1px solid #e3d4bd; + border-radius: 14px; + padding: 16px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); + margin-bottom: 16px; } -.card h2 { margin-top: 0; margin-bottom: 8px; } - -.split { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; flex-wrap: wrap; } - -.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 #d5c7b5; - background: #fffaf3; - color: #2c1c0d; - padding: 10px 12px; - min-width: 0; +.card h2 { + margin-top: 0; + margin-bottom: 8px; } -textarea { min-height: 80px; resize: vertical; } +.split { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + flex-wrap: wrap; +} + +.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 #d5c7b5; + background: #fffaf3; + color: #2c1c0d; + padding: 10px 12px; + min-width: 0; +} + +textarea { + min-height: 80px; + resize: vertical; +} button { - cursor: pointer; - background: linear-gradient(-5deg, #30afea, #80e2ff); - border-color: #124b88; - font-weight: 700; - color: #2c1c0d; + cursor: pointer; + background: linear-gradient(-5deg, #30afea, #80e2ff); + border-color: #124b88; + font-weight: 700; + color: #2c1c0d; } -button:hover { background: linear-gradient(-5deg, #40e2ff, #e0f0ff ); } -button.danger { background: #e0564f; border-color: #c54740; color: #fffaf3; } +button:hover { + background: linear-gradient(-5deg, #40e2ff, #e0f0ff); +} -button.ghost { background: transparent; border-color: #d5c7b5; color: #2c1c0d; } +button.danger { + background: #e0564f; + border-color: #c54740; + color: #fffaf3; +} -.label { color: #6c5a42; font-size: 14px; } -.hint { color: #8c7a63; font-size: 12px; margin: 8px 0 12px 0; } -.hint.warning { color: #c26c1a; } -.disabled-form { opacity: 0.5; pointer-events: none; } +button.ghost { + background: transparent; + border-color: #d5c7b5; + color: #2c1c0d; +} + +.label { + color: #6c5a42; + font-size: 14px; +} +.hint { + color: #8c7a63; + font-size: 12px; + margin: 8px 0 12px 0; +} +.hint.warning { + color: #c26c1a; +} +.disabled-form { + opacity: 0.5; + pointer-events: none; +} .card-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 12px; - margin-top: 12px; - width: 100%; - max-width: 1280px; - margin-inline: auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 12px; + margin-top: 12px; + width: 100%; + max-width: 1280px; + margin-inline: auto; +} +.results-grid { + max-width: none; } -.results-grid { max-width: none; } .game-card { - background: #fffaf3; - border: 1px solid #e5d6c2; - border-radius: 12px; - overflow: hidden; - display: flex; - flex-direction: column; - min-height: 220px; + background: #fffaf3; + border: 1px solid #e5d6c2; + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 220px; } @media (max-width: 1200px) { - .grid { min-width: auto; width: 100%; } - .suggest-grid { grid-template-columns: 1fr; } + .grid { + min-width: auto; + width: 100%; + } + .suggest-grid { + grid-template-columns: 1fr; + } } .card-visual { - height: 200px; - background: linear-gradient(135deg, #f0d9b5, #f6b24f); - background-size: cover; - background-position: center; - background-repeat: no-repeat; - background-color: #f6b24f; - cursor: pointer; - border: none; - width: 100%; - display: block; - padding: 0; + height: 200px; + background: linear-gradient(135deg, #f0d9b5, #f6b24f); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-color: #f6b24f; + cursor: pointer; + border: none; + width: 100%; + display: block; + padding: 0; +} +.card-visual.hovering { + cursor: zoom-in; } -.card-visual.hovering { cursor: zoom-in; } -.card-body { padding: 12px; display: flex; flex-direction: column; gap: 6px; flex: 1; } +.card-body { + padding: 12px; + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} -h3 { margin: 0; font-size: 18px; } +h3 { + margin: 0; + font-size: 18px; +} -.card-title-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; } -.card-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.title-meta { display: flex; align-items: center; gap: 8px; } -.title-meta .chip { max-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -p { margin: 0; } -.muted { color: #7a6a53; margin: 0; } -.link { color: #30afea; text-decoration: none; font-weight: 700; } -.link:hover { text-decoration: underline; } -.link.compact { font-size: 14px; } -.auth-toggle-link { text-align: center; display: inline-block; } -.chip { background: #c5dff1; color: #2c1c0d; padding: 4px 8px; border-radius: 999px; font-size: 12px; } -.chip.danger-chip { background: #e0564f; border: 1px solid #c54740; color: #fffaf3; } +.card-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-width: 0; +} +.card-title { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.title-meta { + display: flex; + align-items: center; + gap: 8px; +} +.title-meta .chip { + max-width: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +p { + margin: 0; +} +.muted { + color: #7a6a53; + margin: 0; +} +.link { + color: #30afea; + text-decoration: none; + font-weight: 700; +} +.link:hover { + text-decoration: underline; +} +.link.compact { + font-size: 14px; +} +.auth-toggle-link { + text-align: center; + display: inline-block; + margin-top: 10px; +} +.chip { + background: #c5dff1; + color: #2c1c0d; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; +} +.chip.danger-chip { + background: #e0564f; + border: 1px solid #c54740; + color: #fffaf3; +} -.vote-controls { display: flex; gap: 10px; align-items: center; margin-top: auto; padding-top: 6px; } -.score { font-weight: 700; min-width: 36px; text-align: center; } -.score-emoji { font-size: 24px; text-align: center; } +.vote-controls { + display: flex; + gap: 10px; + align-items: center; + margin-top: auto; + padding-top: 6px; +} +.score { + font-weight: 700; + min-width: 36px; + text-align: center; +} +.score-emoji { + font-size: 24px; + text-align: center; +} -.results-grid .game-card { border-color: #f0c56b; } +.results-grid .game-card { + border-color: #f0c56b; +} .results-frame { - background: rgba(255, 255, 255, 0.92); - border: 1px solid #e3d4bd; - border-radius: 14px; - padding: 12px; - box-shadow: 0 10px 28px rgba(0,0,0,0.12); + background: rgba(255, 255, 255, 0.92); + border: 1px solid #e3d4bd; + border-radius: 14px; + padding: 12px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12); } /* Slider */ input[type="range"].full-slider { - -webkit-appearance: none; - width: 100%; - height: 20px; - border-radius: 999px; - background: linear-gradient(90deg, #c52222, #d5c522, #22c55e); - outline: none; - box-shadow: inset 0 0 0 1px #e3d4bd, 0 4px 12px rgba(0,0,0,0.18); + -webkit-appearance: none; + width: 100%; + height: 20px; + border-radius: 999px; + background: linear-gradient(90deg, #c52222, #d5c522, #22c55e); + outline: none; + box-shadow: + inset 0 0 0 1px #e3d4bd, + 0 4px 12px rgba(0, 0, 0, 0.18); } input[type="range"].full-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 28px; - height: 28px; - border-radius: 50%; - background: #fffaf3; - border: 2px solid #d5c7b5; - box-shadow: 0 4px 10px rgba(0,0,0,0.25); + -webkit-appearance: none; + appearance: none; + width: 28px; + height: 28px; + border-radius: 50%; + background: #fffaf3; + border: 2px solid #d5c7b5; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25); } input[type="range"].full-slider::-moz-range-thumb { - width: 28px; - height: 28px; - border-radius: 50%; - background: #fffaf3; - border: 2px solid #d5c7b5; - box-shadow: 0 4px 10px rgba(0,0,0,0.25); + width: 28px; + height: 28px; + border-radius: 50%; + background: #fffaf3; + border: 2px solid #d5c7b5; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25); } input[type="range"].full-slider::-moz-range-track { - height: 14px; - border-radius: 999px; - background: linear-gradient(90deg, #f28b3c, #f2c94c, #2ca25f); - border: 1px solid #e3d4bd; + height: 14px; + border-radius: 999px; + background: linear-gradient(90deg, #f28b3c, #f2c94c, #2ca25f); + border: 1px solid #e3d4bd; } -.score { font-weight: 700; min-width: 36px; font-size: 24px; text-align: center; } +.score { + font-weight: 700; + min-width: 36px; + font-size: 24px; + text-align: center; +} -.hidden { display: none !important; } +.hidden { + display: none !important; +} .toast { - position: fixed; - bottom: 16px; - right: 16px; - background: #1db4ac; - color: #0f2f2d; - padding: 10px 14px; - border-radius: 8px; - box-shadow: 0 10px 24px rgba(0,0,0,0.2); - max-width: 320px; + position: fixed; + bottom: 16px; + right: 16px; + background: #1db4ac; + color: #0f2f2d; + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2); + max-width: 320px; +} +.toast.error { + background: #e0564f; + color: #fffaf3; } -.toast.error { background: #e0564f; color: #fffaf3; } -.auth-card .active { font-weight: 700; } -.auth-form { margin-top: 8px; } +.auth-card .active { + font-weight: 700; +} +.auth-form { + margin-top: 8px; +} .auth-logo { - display: flex; - justify-content: center; - margin-bottom: 12px; + display: flex; + justify-content: center; + margin-bottom: 12px; } .auth-logo img { - max-width: 200px; - width: 100%; - height: auto; - filter: drop-shadow(0 6px 16px rgba(0,0,0,0.25)); + max-width: 200px; + width: 100%; + height: auto; + filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.25)); } .admin-toggle { - position: fixed; - bottom: 18px; - right: 18px; - width: 44px; - height: 44px; - border-radius: 50%; - border: 1px solid #e3d4bd; - background: rgba(255,255,255,0.9); - color: #6c5a42; - font-weight: 700; - box-shadow: 0 8px 20px rgba(0,0,0,0.18); - z-index: 30; + position: fixed; + bottom: 18px; + right: 18px; + width: 44px; + height: 44px; + border-radius: 50%; + border: 1px solid #e3d4bd; + background: rgba(255, 255, 255, 0.9); + color: #6c5a42; + font-weight: 700; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18); + z-index: 30; } .admin-panel { - position: fixed; - bottom: 70px; - right: 18px; - width: 320px; - z-index: 40; - display: flex; - flex-direction: column; - gap: 10px; + position: fixed; + bottom: 70px; + right: 18px; + width: 320px; + z-index: 40; + display: flex; + flex-direction: column; + gap: 10px; } .lightbox { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; } .lightbox-content { - position: relative; - max-width: 90vw; - max-height: 90vh; - background: #fffaf3; - padding: 12px; - border-radius: 12px; - box-shadow: 0 20px 50px rgba(0,0,0,0.35); + position: relative; + max-width: 90vw; + max-height: 90vh; + background: #fffaf3; + padding: 12px; + border-radius: 12px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); } .lightbox-content img { - max-width: 100%; - max-height: 80vh; - display: block; - border-radius: 8px; + max-width: 100%; + max-height: 80vh; + display: block; + border-radius: 8px; } .lightbox-close { - position: absolute; - top: 8px; - right: 8px; - background: #1db4ac; - color: #0f2f2d; - border: 1px solid #128b88; - border-radius: 999px; - width: 32px; - height: 32px; - font-size: 16px; - padding: 0; - cursor: pointer; + position: absolute; + top: 8px; + right: 8px; + background: #1db4ac; + color: #0f2f2d; + border: 1px solid #128b88; + border-radius: 999px; + width: 32px; + height: 32px; + font-size: 16px; + padding: 0; + cursor: pointer; } .edit-modal { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.55); - display: flex; - align-items: center; - justify-content: center; - z-index: 110; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 110; } .edit-modal .edit-panel { - background: #fffaf3; - border: 1px solid #e3d4bd; - border-radius: 12px; - width: min(960px, 94vw); - max-height: 92vh; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; - box-shadow: 0 20px 48px rgba(0,0,0,0.25); + background: #fffaf3; + border: 1px solid #e3d4bd; + border-radius: 12px; + width: min(960px, 94vw); + max-height: 92vh; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.25); +} +.edit-modal .edit-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.edit-modal .edit-body { + overflow: auto; + max-height: 70vh; } -.edit-modal .edit-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.edit-modal .edit-body { overflow: auto; max-height: 70vh; } -.panel-header { display: flex; justify-content: space-between; align-items: center; } +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; +} -.results-table { width: 100%; border-collapse: collapse; } -.results-table th, .results-table td { padding: 10px; } -.results-table tr { border-bottom: 1px solid #e3d4bd; } -.results-table th { text-align: left; color: #7a6a53; font-size: 12px; letter-spacing: 0.3px; } -.results-table .game-cell { display: flex; gap: 10px; align-items: center; min-width: 0; } -.results-table .thumb { width: 72px; height: 48px; object-fit: cover; border-radius: 6px; border: 1px solid #e3d4bd; cursor: pointer; } -.results-table .game-meta { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } -.results-table .title-line { font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.results-table .author-cell { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.results-table .muted.small { font-size: 12px; color: #7a6a53; } -.thumb-open { background: #fffaf3; border: 1px solid #e3d4bd; color: #2c1c0d; border-radius: 6px; padding: 4px 8px; cursor: pointer; } +.results-table { + width: 100%; + border-collapse: collapse; +} +.results-table th, +.results-table td { + padding: 10px; +} +.results-table tr { + border-bottom: 1px solid #e3d4bd; +} +.results-table th { + text-align: left; + color: #7a6a53; + font-size: 12px; + letter-spacing: 0.3px; +} +.results-table .game-cell { + display: flex; + gap: 10px; + align-items: center; + min-width: 0; +} +.results-table .thumb { + width: 72px; + height: 48px; + object-fit: cover; + border-radius: 6px; + border: 1px solid #e3d4bd; + cursor: pointer; +} +.results-table .game-meta { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} +.results-table .title-line { + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.results-table .author-cell { + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.results-table .muted.small { + font-size: 12px; + color: #7a6a53; +} +.thumb-open { + background: #fffaf3; + border: 1px solid #e3d4bd; + color: #2c1c0d; + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; +}