Files
GameList/wwwroot/app.js

794 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { api, adminApi } from "./js/api.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
};
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);
}
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;
}
}
}
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");
}
}
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");
}
function handleAuthError(err) {
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();
}
async function loadSuggestData() {
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;
}
}
}
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();
}
}
async function loadResults() {
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";
}
}
function renderCounts() {
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 });
}
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 })));
}
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();
}
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 = `
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}">
<span class="score" id="score-${s.id}">${displayScore}</span>
<span class="score-emoji" id="emoji-${s.id}">${displayEmoji}</span>`;
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);
});
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();
});
}
function renderResults() {
const container = $("results-list");
container.innerHTML = "";
const table = document.createElement("table");
table.className = "results-table";
table.innerHTML = `
<thead>
<tr>
<th>${t("results.rank")}</th>
<th>${t("results.game")}</th>
<th>${t("results.author")}</th>
<th>${t("results.votes")}</th>
<th>${t("results.avg")}</th>
<th>${t("results.total")}</th>
<th>${t("results.links")}</th>
</tr>
</thead>
<tbody></tbody>
`;
const tbody = table.querySelector("tbody");
state.results.forEach((r, idx) => {
const row = document.createElement("tr");
row.innerHTML = `
<td>${idx + 1}</td>
<td class="game-cell">
${r.screenshotUrl ? `<img class="thumb clickable-thumb" src="${r.screenshotUrl}" alt="${r.name}">` : ''}
<div class="game-meta">
<div class="title-line">${r.name}</div>
${r.genre ? `<div class="muted small">${r.genre}</div>` : ''}
</div>
</td>
<td>${r.author ?? "—"}</td>
<td>${r.count}</td>
<td>${r.average.toFixed(1)}</td>
<td>${r.total}</td>
<td>
${r.gameUrl ? `<a class="link compact" href="${r.gameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ''}
${r.youtubeUrl ? `<a class="link compact" href="${r.youtubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ''}
</td>
`;
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");
}
}
function setupHandlers() {
const toggleAuth = $("auth-toggle");
if (toggleAuth) {
toggleAuth.addEventListener("click", () => 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 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(() => {
syncLanguageSelects();
renderWelcome();
renderPhasePill();
renderCounts();
renderPhaseTitles();
renderMySuggestions();
renderAllSuggestions();
if (state.phase === "Vote") {
renderVotes();
state.votesRendered = true;
syncVoteScores();
}
if (state.phase === "Results") {
renderResults();
}
});
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);
}
});
$("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 = "";
}
});
}
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);
}
}
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();
}
} 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
? `<button class="card-visual" data-img="${s.screenshotUrl}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${s.screenshotUrl}')"></button>`
: `<div class="card-visual"></div>`;
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}
<div class="card-body">
<div class="card-title-row">
<h3 class="card-title" title="${s.name}">${s.name}</h3>
<div class="title-meta">
${showAuthor && s.author ? `<span class="chip">${s.author}</span>` : ""}
${allowEdit ? `<button class="chip" data-edit="${s.id}" type="button">${t("card.edit")}</button>` : ""}
${allowDelete ? `<button class="chip danger-chip" data-delete="${s.id}" type="button">${t("card.delete")}</button>` : ""}
</div>
</div>
${hasExtraInfo ? `<p class="muted">` : ""}
${genreAndPlayers ? genreAndPlayers : ""}
${s.gameUrl ? `<a class="link compact" href="${s.gameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
${s.youtubeUrl ? `<a class="link compact" href="${s.youtubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
${hasExtraInfo ? `</p>` : ""}
${s.description ? `<p>${s.description}</p>` : ""}
</div>
`;
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 = `
<div class="edit-panel">
<div class="edit-header">
<h3>${t("modal.editTitle")}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">×</button>
</div>
<div class="edit-body">
<form class="stack" id="edit-form">
<label class="stack">
<span class="label" data-i18n="form.gameName">${t("form.gameName")}</span>
<input name="name" required maxlength="100" value="${s.name ?? ""}" />
</label>
<label class="stack">
<span class="label" data-i18n="form.genre">${t("form.genre")}</span>
<input name="genre" maxlength="50" value="${s.genre ?? ""}" />
</label>
<label class="stack">
<span class="label" data-i18n="form.description">${t("form.description")}</span>
<textarea name="description" maxlength="500">${s.description ?? ""}</textarea>
</label>
<div class="stack">
<span class="label">${t("form.players")}</span>
<div class="stack horizontal">
<label class="stack">
<span class="label">${t("form.min")}</span>
<input name="minPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.minPlayers ?? ""}" />
</label>
<label class="stack">
<span class="label">${t("form.max")}</span>
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" value="${s.maxPlayers ?? ""}" />
</label>
</div>
</div>
<label class="stack">
<span class="label" data-i18n="form.screenshot">${t("form.screenshot")}</span>
<input name="screenshotUrl" maxlength="2048" value="${s.screenshotUrl ?? ""}" />
</label>
<label class="stack">
<span class="label" data-i18n="form.youtube">${t("form.youtube")}</span>
<input name="youtubeUrl" maxlength="2048" value="${s.youtubeUrl ?? ""}" />
</label>
<label class="stack">
<span class="label" data-i18n="form.gameUrl">${t("form.gameUrl")}</span>
<input name="gameUrl" maxlength="2048" value="${s.gameUrl ?? ""}" />
</label>
<div class="stack horizontal">
<button type="submit">${t("modal.save")}</button>
<button type="button" class="ghost" id="edit-cancel">${t("modal.cancel")}</button>
</div>
</form>
</div>
</div>
`;
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 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);
}
function openLightbox(url, title) {
const overlay = document.createElement("div");
overlay.className = "lightbox";
overlay.innerHTML = `
<div class="lightbox-content">
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
<img src="${url}" alt="${title}" />
<p>${title || ""}</p>
</div>
`;
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;
};
const reset = () => {
el.classList.remove("hovering");
el.style.backgroundSize = "";
el.style.backgroundPosition = "";
};
el.addEventListener("mouseenter", () => {
el.classList.add("hovering");
el.style.backgroundSize = "auto";
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}%`;
});
["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);
}
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;
}
}
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),
};
}
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 "🤩";
}
function neutralEmoji() {
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
])
);
}