Split frontend UI into feature modules
This commit is contained in:
470
wwwroot/js/suggestions-ui.js
Normal file
470
wwwroot/js/suggestions-ui.js
Normal file
@@ -0,0 +1,470 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user