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 = `
${t("form.players")}
`; 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 = `

${title}

`; 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 = `

${t("modal.confirmDeleteTitle")}

`; 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); } }, }); }