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 {
escapeHtml,
isLinked,
linkedPeerTitles,
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();
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
? ``
: "";
const visual =
hasImage && safeShot
? ``
: `
`;
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}
${nameText}
${linkChip}
${showAuthor && s.author ? `${authorText}` : ""}
${allowEdit ? `` : ""}
${allowDelete ? `` : ""}
${hasExtraInfo ? `
` : ""}
${genreAndPlayers ? genreAndPlayers : ""}
${safeGameUrl ? `${t("card.site")}` : ""}
${safeYoutubeUrl ? `${t("card.youtube")}` : ""}
${hasExtraInfo ? `
` : ""}
${s.description ? `
${descText}
` : ""}
`;
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 = `
`;
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 = `
`;
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 = `
`;
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);
}
},
});
}