496 lines
17 KiB
JavaScript
496 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,
|
|
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);
|
|
}
|
|
},
|
|
});
|
|
}
|