Split auth UI module and load FAQ from markdown assets

This commit is contained in:
2026-02-07 02:28:21 +01:00
parent 5f31455651
commit 536e6392f0
6 changed files with 514 additions and 94 deletions

72
wwwroot/js/auth-ui.js Normal file
View File

@@ -0,0 +1,72 @@
import { t } from "./i18n.js";
import { state, getSavedUsername } from "./state.js";
import { $, toast } from "./dom.js";
export function setAuthUI(isAuthed) {
const main = document.querySelector("main");
const statusBar = document.querySelector(".status-bar");
const authCard = $("auth-card");
[main, statusBar].forEach((el) =>
el?.classList.toggle("hidden", !isAuthed),
);
if (authCard) authCard.classList.toggle("hidden", isAuthed);
const adminToggle = $("admin-toggle");
if (adminToggle)
adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin);
if (!isAuthed) {
const adminCard = $("admin-card");
if (adminCard) adminCard.classList.add("hidden");
const loginUser = $("login-username");
const cachedUser = getSavedUsername();
if (
loginUser &&
cachedUser &&
!loginUser.dataset.userEditing &&
!loginUser.value
) {
loginUser.value = cachedUser;
}
}
}
export function setAuthMode(mode) {
state.authMode = mode;
document.querySelectorAll(".auth-form").forEach((form) => {
form.classList.toggle("hidden", form.dataset.mode !== mode);
});
const title = $("auth-title");
const toggleBtn = $("auth-toggle");
if (title) {
title.textContent =
mode === "login"
? t("auth.loginHeading")
: t("auth.registerHeading");
}
if (toggleBtn) {
toggleBtn.textContent =
mode === "login"
? t("auth.switchToRegister")
: t("auth.switchToLogin");
}
}
export function handleAuthError(err, clearUserState) {
if (err?.status === 401) {
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
return true;
}
toast(err?.message || t("toast.unexpected"), true);
return false;
}
export function renderWelcome() {
const el = $("welcome-text");
if (!el) return;
const name =
state.me?.displayName?.trim() ||
state.me?.username ||
t("auth.defaultName");
el.textContent = t("auth.welcome", { name });
}

View File

@@ -4,7 +4,7 @@ const translationsAssetUrl = new URL(
"../data/i18n/translations.json",
import.meta.url,
);
const faqAssetUrl = new URL("../data/i18n/faq.json", import.meta.url);
const faqBaseUrl = new URL("../data/i18n/faq/", import.meta.url);
let currentLang = defaultLang;
const listeners = [];
@@ -62,20 +62,6 @@ function validateTranslations(raw) {
return raw;
}
function validateFaq(raw) {
if (!isRecord(raw) || typeof raw[defaultLang] !== "string") {
throw new Error(`Missing default FAQ language "${defaultLang}".`);
}
Object.entries(raw).forEach(([lang, value]) => {
if (typeof value !== "string") {
throw new Error(`Invalid FAQ value for "${lang}".`);
}
});
return raw;
}
async function loadJson(url, label) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
@@ -85,18 +71,61 @@ async function loadJson(url, label) {
return response.json();
}
async function loadText(url, label) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load ${label}: ${response.status}`);
}
return response.text();
}
async function loadFaqMarkdownForLanguages(languages) {
if (!Array.isArray(languages) || languages.length === 0) {
throw new Error("No i18n languages available for FAQ loading.");
}
const faqByLanguage = {};
for (const lang of languages) {
const faqUrl = new URL(`${lang}.md`, faqBaseUrl);
try {
faqByLanguage[lang] = await loadText(faqUrl, `faq.${lang}`);
} catch (err) {
if (lang === defaultLang) {
throw err;
}
}
}
const fallback = faqByLanguage[defaultLang];
if (typeof fallback !== "string" || fallback.trim().length === 0) {
throw new Error(`Missing default FAQ language "${defaultLang}".`);
}
languages.forEach((lang) => {
const value = faqByLanguage[lang];
faqByLanguage[lang] =
typeof value === "string" && value.trim().length > 0
? value
: fallback;
});
return faqByLanguage;
}
async function ensureAssetsLoaded() {
if (assetsLoaded) return;
if (assetsLoadingPromise) return assetsLoadingPromise;
assetsLoadingPromise = (async () => {
const [translationsRaw, faqRaw] = await Promise.all([
loadJson(translationsAssetUrl, "translations"),
loadJson(faqAssetUrl, "faq"),
]);
const translationsRaw = await loadJson(
translationsAssetUrl,
"translations",
);
translations = validateTranslations(translationsRaw);
faqMarkdown = validateFaq(faqRaw);
faqMarkdown = await loadFaqMarkdownForLanguages(
Object.keys(translations),
);
assetsLoaded = true;
})().finally(() => {
assetsLoadingPromise = null;

View File

@@ -1,7 +1,13 @@
import { t } from "./i18n.js";
import { state, getSavedUsername } from "./state.js";
import { $, toast } from "./dom.js";
import { state } from "./state.js";
import { $ } from "./dom.js";
import { configureUiRuntime } from "./ui-runtime.js";
import {
setAuthUI,
setAuthMode,
handleAuthError,
renderWelcome,
} from "./auth-ui.js";
import {
renderMySuggestions,
renderAllSuggestions,
@@ -25,65 +31,6 @@ import {
openSuggestionsChangedModal,
} from "./modals-ui.js";
export function setAuthUI(isAuthed) {
const main = document.querySelector("main");
const statusBar = document.querySelector(".status-bar");
const authCard = $("auth-card");
[main, statusBar].forEach((el) =>
el?.classList.toggle("hidden", !isAuthed),
);
if (authCard) authCard.classList.toggle("hidden", isAuthed);
const adminToggle = $("admin-toggle");
if (adminToggle)
adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin);
if (!isAuthed) {
const adminCard = $("admin-card");
if (adminCard) adminCard.classList.add("hidden");
const loginUser = $("login-username");
const cachedUser = getSavedUsername();
if (
loginUser &&
cachedUser &&
!loginUser.dataset.userEditing &&
!loginUser.value
) {
loginUser.value = cachedUser;
}
}
}
export function setAuthMode(mode) {
state.authMode = mode;
document.querySelectorAll(".auth-form").forEach((form) => {
form.classList.toggle("hidden", form.dataset.mode !== mode);
});
const title = $("auth-title");
const toggleBtn = $("auth-toggle");
if (title) {
title.textContent =
mode === "login"
? t("auth.loginHeading")
: t("auth.registerHeading");
}
if (toggleBtn) {
toggleBtn.textContent =
mode === "login"
? t("auth.switchToRegister")
: t("auth.switchToLogin");
}
}
export function handleAuthError(err, clearUserState) {
if (err?.status === 401) {
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
return true;
}
toast(err?.message || t("toast.unexpected"), true);
return false;
}
export function renderPhasePill() {
document
.querySelectorAll(".phase-view")
@@ -108,19 +55,10 @@ export function renderCounts() {
});
}
export function renderWelcome() {
const el = $("welcome-text");
if (!el) return;
const name =
state.me?.displayName?.trim() ||
state.me?.username ||
t("auth.defaultName");
el.textContent = t("auth.welcome", { name });
}
export {
buildCard,
configureUiRuntime,
handleAuthError,
neutralEmoji,
normalizeSuggestionForm,
openConfirmModal,
@@ -130,10 +68,13 @@ export {
openSuggestionsChangedModal,
renderAllSuggestions,
renderMySuggestions,
renderWelcome,
renderPhaseTitles,
renderResults,
renderVotes,
scoreToEmoji,
setAuthMode,
setAuthUI,
syncVoteScores,
updatePhaseNav,
};