Files
GameList/wwwroot/app.js

266 lines
6.6 KiB
JavaScript

import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
import { state, clearUserState } from "./js/state.js";
import { toast } from "./js/dom.js";
import {
handleAuthError,
renderWelcome,
renderPhasePill,
renderCounts,
renderMySuggestions,
renderAllSuggestions,
renderVotes,
syncVoteScores,
renderResults,
renderPhaseTitles,
updatePhaseNav,
configureUiRuntime,
} from "./js/ui.js";
import {
loadSuggestData,
loadVoteData,
refreshPhaseData,
} from "./js/data.js";
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
const REFRESH_INTERVAL_MS = 4000;
let refreshInFlight = null;
let refreshTimerId = null;
let refreshSchedulerStarted = false;
async function runSerializedRefresh() {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = refreshPhaseData().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
async function refreshWithUiErrorHandling() {
try {
await runSerializedRefresh();
} catch (err) {
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
}
}
function scheduleNextRefresh() {
refreshTimerId = window.setTimeout(async () => {
if (!document.hidden && !state.adminStatusSelectActive) {
await refreshWithUiErrorHandling();
}
scheduleNextRefresh();
}, REFRESH_INTERVAL_MS);
}
function startRefreshScheduler() {
if (refreshSchedulerStarted) return;
refreshSchedulerStarted = true;
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling();
}
});
if (refreshTimerId !== null) {
window.clearTimeout(refreshTimerId);
}
scheduleNextRefresh();
}
configureUiRuntime({
refreshPhaseData: runSerializedRefresh,
loadSuggestData,
loadVoteData,
handleAuthError: (err) => handleAuthError(err, clearUserState),
});
function setupHandlers() {
setupAuthHandlers({ runSerializedRefresh });
setupAdminHandlers({ runSerializedRefresh });
setupVoteNavigationHandlers({ runSerializedRefresh });
setupLanguageSwitchers();
onLanguageChange(() => {
updateLanguageButtons();
renderWelcome();
renderPhasePill();
renderCounts();
renderPhaseTitles();
renderMySuggestions();
renderAllSuggestions();
if (state.phase === "Vote") {
renderVotes();
state.votesRendered = true;
syncVoteScores();
}
if (state.phase === "Results") {
renderResults();
}
updatePhaseNav();
});
document.querySelectorAll(".help-chip").forEach((chip) => {
chip.addEventListener("click", () => openFaqModal());
});
}
async function main() {
await initI18n();
setupHandlers();
await refreshWithUiErrorHandling();
startRefreshScheduler();
}
main();
function updateLanguageButtons() {
document.querySelectorAll(".lang-button").forEach((btn) => {
btn.textContent = "🌐";
btn.title = t("lang.label");
btn.setAttribute("aria-label", t("lang.label"));
});
}
function setupLanguageSwitchers() {
const switches = document.querySelectorAll(".lang-switch");
const closeAll = () =>
switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden"));
switches.forEach((wrap) => {
const btn = wrap.querySelector(".lang-button");
const menu = wrap.querySelector(".lang-menu");
if (!btn || !menu) return;
btn.addEventListener("click", (e) => {
e.preventDefault();
const isHidden = menu.classList.contains("hidden");
closeAll();
if (isHidden) menu.classList.remove("hidden");
});
menu.querySelectorAll("[data-lang]").forEach((item) =>
item.addEventListener("click", () => {
const lang = item.dataset.lang;
if (lang) setLanguage(lang);
closeAll();
}),
);
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".lang-switch")) closeAll();
});
updateLanguageButtons();
}
function markdownToHtml(md) {
const lines = md.trim().split(/\r?\n/);
const html = [];
let inList = false;
let inParagraph = false;
const escapeHtml = (text) =>
text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const formatInline = (text) =>
escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>");
const closeParagraph = () => {
if (inParagraph) {
html.push("</p>");
inParagraph = false;
}
};
const closeList = () => {
if (inList) {
html.push("</ul>");
inList = false;
}
};
lines.forEach((rawLine) => {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed) {
closeParagraph();
closeList();
return;
}
if (/^-{5,}$/.test(trimmed)) {
closeParagraph();
closeList();
html.push('<hr class="faq-divider" />');
return;
}
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
if (heading) {
closeParagraph();
closeList();
const level = heading[1].length;
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
return;
}
if (/^[*-]\s+/.test(trimmed)) {
closeParagraph();
if (!inList) {
html.push("<ul>");
inList = true;
}
const text = trimmed.replace(/^[*-]\s+/, "");
html.push(`<li>${formatInline(text)}</li>`);
return;
}
if (!inParagraph) {
html.push("<p>");
inParagraph = true;
}
html.push(formatInline(trimmed));
});
closeParagraph();
closeList();
return html.join("\n");
}
function openFaqModal() {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel faq-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${t("help.title")}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body">
<div class="faq-list faq-content"></div>
</div>
`;
const list = panel.querySelector(".faq-list");
const lang = getLanguage();
const md = faqMarkdown[lang] ?? faqMarkdown.en;
list.innerHTML = markdownToHtml(md);
const close = () => overlay.remove();
overlay.addEventListener("click", (e) => {
if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close();
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
}