Files
GameList/wwwroot/js/suggestions-ui.js

471 lines
17 KiB
JavaScript

import { api, adminApi } from "./api.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import { $, toast } from "./dom.js";
import { setupCardVisualHover, triggerCelebration } from "./effects.js";
import { renderAdminLinker } from "./admin-ui.js";
import { getUiRuntime } from "./ui-runtime.js";
import {
cssEscapeUrl,
escapeHtml,
isLinked,
linkedPeerTitles,
renderLinkBadge,
safeUrl,
sortByName,
} from "./ui-utils.js";
import { openConfirmModal, openLightbox } from "./modals-ui.js";
function updateSuggestButtonState() {
const btn = $("open-suggest-modal");
if (!btn) return;
const limit = 5;
const count = state.mySuggestions?.length ?? 0;
const blocked = count >= limit;
btn.disabled = blocked || state.phase !== "Suggest";
btn.textContent = blocked ? t("suggest.maxReached") : t("suggest.addButton");
}
export function renderMySuggestions() {
const wrap = $("my-suggestions");
if (!wrap) return;
wrap.innerHTML = "";
const allowEdit = true;
const lockTitle = state.phase !== "Suggest" && !state.me?.isAdmin;
const allowDelete = state.phase === "Suggest" || state.me?.isAdmin;
sortByName(state.mySuggestions).forEach((s) =>
wrap.appendChild(
buildCard(s, { showAuthor: false, allowDelete, allowEdit, lockTitle }),
),
);
updateSuggestButtonState();
}
export function renderAllSuggestions() {
renderAdminLinker();
const list = $("all-suggestions");
if (!list) return;
list.innerHTML = "";
const allowEdit = true;
const allowDelete = !!state.me?.isAdmin;
sortByName(state.allSuggestions).forEach((s) =>
list.appendChild(
buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
),
);
renderPhaseTitles();
}
export function renderPhaseTitles() {
const voteTitle = $("vote-title");
const totalGames = state.allSuggestions?.length ?? 0;
if (voteTitle) {
voteTitle.textContent =
totalGames > 0
? t("section.vote.count", { count: totalGames })
: t("section.vote");
}
}
export function buildCard(
s,
{ showAuthor = false, allowDelete = false, allowEdit = false, lockTitle = false },
) {
const card = document.createElement("article");
card.className = "game-card";
const hasImage = !!s.screenshotUrl;
const safeShot = safeUrl(s.screenshotUrl);
const nameText = escapeHtml(s.name);
const genreText = escapeHtml(s.genre);
const descText = escapeHtml(s.description);
const authorText = escapeHtml(s.author);
const safeGameUrl = safeUrl(s.gameUrl);
const safeYoutubeUrl = safeUrl(s.youtubeUrl);
const linkedTitles = linkedPeerTitles(s);
const linked = isLinked(s);
const linkTooltipText = linked
? linkedTitles.length > 0
? t("card.linkedWith", { names: linkedTitles.join(", ") })
: t("card.linked")
: "";
const linkTooltipSafe = escapeHtml(linkTooltipText);
const linkChip = linked
? `<button class="chip icon link-chip${state.me?.isAdmin ? " link-chip-action" : ""}" data-unlink="${s.id}" type="button" title="${linkTooltipSafe}">🔗</button>`
: "";
const visual = hasImage && safeShot
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></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
? `${genreText}${players}`
: genreText
: hasPlayers
? players
: undefined;
const hasExtraInfo = genreAndPlayers || safeGameUrl || safeYoutubeUrl;
card.innerHTML = `
${visual}
<div class="card-body">
<div class="card-title-row">
<h3 class="card-title" title="${nameText}">${nameText}</h3>
<div class="title-meta">
${linkChip}
${showAuthor && s.author ? `<span class="chip">${authorText}</span>` : ""}
${allowEdit ? `<button class="chip icon" data-edit="${s.id}" type="button" title="${t("card.edit")}">✏️</button>` : ""}
${allowDelete ? `<button class="chip icon danger-chip" data-delete="${s.id}" type="button" title="${t("card.delete")}">✕</button>` : ""}
</div>
</div>
${hasExtraInfo ? `<p class="muted">` : ""}
${genreAndPlayers ? genreAndPlayers : ""}
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("card.site")}</a>` : ""}
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("card.youtube")}</a>` : ""}
${hasExtraInfo ? `</p>` : ""}
${s.description ? `<p>${descText}</p>` : ""}
</div>
`;
if (hasImage) {
const btn = card.querySelector(".card-visual");
setupCardVisualHover(btn, safeShot);
btn.addEventListener("click", () => openLightbox(safeShot, s.name));
}
if (linked && state.me?.isAdmin) {
const unlinkBtn = card.querySelector("[data-unlink]");
unlinkBtn?.addEventListener("click", () => openUnlinkConfirm(s));
}
if (allowEdit) {
const editBtn = card.querySelector("[data-edit]");
editBtn?.addEventListener("click", () =>
openSuggestionModal({
title: t("modal.editTitle"),
submitLabel: t("modal.save"),
initial: s,
onSubmit: async (data, close) => {
await api.updateSuggestion(s.id, data);
toast(t("toast.savedChanges"));
close();
await getUiRuntime().refreshPhaseData();
},
lockTitle,
}),
);
}
if (allowDelete) {
const del = card.querySelector("[data-delete]");
del.addEventListener("click", async () => {
openDeleteConfirmModal(s);
});
}
return card;
}
function buildSuggestionForm(initial = {}, lockTitle = false) {
const form = document.createElement("form");
form.className = "stack suggestion-form";
form.innerHTML = `
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.gameName">${t("form.gameName")}</span>
<span class="char-counter" data-for="name"></span>
</span>
<input name="name" required maxlength="100" />
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.genre">${t("form.genre")}</span>
<span class="char-counter" data-for="genre"></span>
</span>
<input name="genre" maxlength="50" />
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.description">${t("form.description")}</span>
<span class="char-counter" data-for="description"></span>
</span>
<textarea name="description" maxlength="500"></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" />
</label>
<label class="stack">
<span class="label">${t("form.max")}</span>
<input name="maxPlayers" type="number" min="1" max="32" inputmode="numeric" />
</label>
</div>
<div class="form-error hidden" data-error="players"></div>
</div>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.screenshot">${t("form.screenshot")}</span>
<span class="char-counter" data-for="screenshotUrl"></span>
</span>
<input name="screenshotUrl" maxlength="2048" />
<p class="hint" data-i18n="form.screenshotHint">${t("form.screenshotHint")}</p>
<div class="form-error hidden" data-error="screenshot"></div>
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.youtube">${t("form.youtube")}</span>
<span class="char-counter" data-for="youtubeUrl"></span>
</span>
<input name="youtubeUrl" maxlength="2048" />
</label>
<label class="stack">
<span class="label-row">
<span class="label" data-i18n="form.gameUrl">${t("form.gameUrl")}</span>
<span class="char-counter" data-for="gameUrl"></span>
</span>
<input name="gameUrl" maxlength="2048" />
</label>
`;
const setVal = (name, value) => {
const input = form.querySelector(`[name="${name}"]`);
if (input) {
input.value = value ?? "";
if (name === "name" && lockTitle) {
input.readOnly = true;
input.classList.add("readonly");
}
}
};
setVal("name", initial.name ?? "");
setVal("genre", initial.genre ?? "");
const desc = form.querySelector("textarea[name=description]");
if (desc) desc.value = initial.description ?? "";
setVal("minPlayers", initial.minPlayers ?? "");
setVal("maxPlayers", initial.maxPlayers ?? "");
setVal("screenshotUrl", initial.screenshotUrl ?? "");
setVal("youtubeUrl", initial.youtubeUrl ?? "");
setVal("gameUrl", initial.gameUrl ?? "");
initCharCounters(form);
return form;
function initCharCounters(formEl) {
const inputs = formEl.querySelectorAll("input[maxlength], textarea[maxlength]");
inputs.forEach((input) => {
const counter = formEl.querySelector(`.char-counter[data-for="${input.name}"]`);
if (!counter) return;
const update = () => {
const max = input.maxLength;
if (!max || max < 0) return;
const used = input.value?.length ?? 0;
counter.textContent = `${used}/${max}`;
};
input.addEventListener("input", update);
update();
});
}
}
function openSuggestionModal({ title, submitLabel, initial = {}, onSubmit, lockTitle = false }) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body"></div>
`;
const form = buildSuggestionForm(initial, lockTitle);
const actions = document.createElement("div");
actions.className = "stack horizontal";
const submitBtn = document.createElement("button");
submitBtn.type = "submit";
submitBtn.textContent = submitLabel;
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "ghost";
cancelBtn.textContent = t("modal.cancel");
actions.append(submitBtn, cancelBtn);
form.appendChild(actions);
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
cancelBtn.addEventListener("click", close);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const data = normalizeSuggestionForm(new FormData(form));
const errorBox = form.querySelector('[data-error="players"]');
const minInput = form.querySelector('input[name="minPlayers"]');
const maxInput = form.querySelector('input[name="maxPlayers"]');
const markError = (msg) => {
if (errorBox) {
errorBox.textContent = msg;
errorBox.classList.remove("hidden");
}
};
const clearError = () => {
if (errorBox) errorBox.classList.add("hidden");
};
clearError();
const min = data.minPlayers;
const max = data.maxPlayers;
const inRange = (v) => v == null || (Number.isInteger(v) && v >= 1 && v <= 32);
const valid =
inRange(min) &&
inRange(max) &&
(min == null || max == null || min <= max);
[minInput, maxInput].forEach((el) =>
el?.classList.toggle("input-error", !valid),
);
if (!valid) {
markError(t("form.playersInvalid"));
return;
}
if (!data.name?.trim()) return toast(t("toast.nameRequired"), true);
try {
await onSubmit(data, close, submitBtn);
} catch (err) {
if (getUiRuntime().handleAuthError?.(err)) return;
toast(err.message, true);
}
});
panel.querySelector(".edit-body")?.appendChild(form);
overlay.appendChild(panel);
document.body.appendChild(overlay);
return overlay;
}
export function openNewSuggestionModal() {
openSuggestionModal({
title: t("modal.addTitle") || t("suggest.new"),
submitLabel: t("form.submit"),
initial: {},
onSubmit: async (data, close, submitBtn) => {
const wasVotePhase = state.phase === "Vote";
await api.createSuggestion(data);
toast(t("toast.suggestionAdded"));
if (submitBtn) triggerCelebration(submitBtn);
close();
if (wasVotePhase) {
await getUiRuntime().refreshPhaseData();
} else {
await getUiRuntime().loadSuggestData();
}
},
});
}
export 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 openDeleteConfirmModal(s) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${t("modal.confirmDeleteTitle")}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body delete-body"></div>
`;
const preview = buildCard(
{ ...s, id: s.id },
{ showAuthor: true, allowDelete: false, allowEdit: false },
);
preview.classList.add("preview-card");
const actions = document.createElement("div");
actions.className = "stack horizontal";
const confirmBtn = document.createElement("button");
confirmBtn.className = "danger";
confirmBtn.textContent = t("modal.confirmDelete");
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "ghost";
cancelBtn.textContent = t("modal.cancel");
actions.append(confirmBtn, cancelBtn);
const body = panel.querySelector(".delete-body");
body?.append(preview, actions);
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
cancelBtn.addEventListener("click", close);
confirmBtn.addEventListener("click", async () => {
try {
await api.deleteSuggestion(s.id);
toast(t("toast.suggestionDeleted"));
close();
await getUiRuntime().loadSuggestData();
} catch (err) {
toast(err.message, true);
}
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
}
function openUnlinkConfirm(s) {
const peers = linkedPeerTitles(s);
const names = peers.length ? peers.join(", ") : t("admin.unlinkUnknownPeers");
openConfirmModal({
title: t("admin.unlinkTitle"),
body: t("admin.unlinkBody", { name: s.name, peers: names }),
confirmLabel: t("admin.unlinkConfirm"),
cancelLabel: t("modal.cancel"),
onConfirm: async (close) => {
try {
await adminApi.unlinkSuggestions(s.id);
toast(t("admin.unlinkDone"));
close();
await getUiRuntime().refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
},
});
}