Files
karczma-aplikacja-stoliki/public/assets/js/modules/i18n.js
T

293 lines
7.6 KiB
JavaScript

import pl from "../locales/pl.js";
import en from "../locales/en.js";
import de from "../locales/de.js";
const STORAGE_KEY = "karczma_lang";
const catalogs = { pl, en, de };
const LANG_BUSY_MIN_MS = 280;
let currentLang = "pl";
let langBusy = false;
/** @type {Set<(lang: string) => void | Promise<void>>} */
const listeners = new Set();
/** @type {Set<(busy: boolean) => void>} */
const busyListeners = new Set();
/** @type {Set<{ el: HTMLElement, close: () => void }>} */
const openPickerClosers = new Set();
let documentClickBound = false;
function interpolate(template, vars = {}) {
return String(template).replace(/\{(\w+)\}/g, (_, key) => {
return vars[key] != null ? String(vars[key]) : `{${key}}`;
});
}
function ensureDocumentClickHandler() {
if (documentClickBound) return;
documentClickBound = true;
document.addEventListener("click", (e) => {
openPickerClosers.forEach((picker) => {
if (!picker.el.contains(e.target)) {
picker.close();
}
});
});
}
function setLangBusy(busy) {
langBusy = !!busy;
document.documentElement.classList.toggle("is-lang-loading", langBusy);
busyListeners.forEach((cb) => {
try {
cb(langBusy);
} catch (err) {
console.warn("[i18n] busy listener failed", err);
}
});
}
export function getAvailableLanguages() {
const fromConfig = window.APP_CONFIG?.languages;
if (Array.isArray(fromConfig) && fromConfig.length) {
return fromConfig;
}
return [
{ code: "pl", label: "Polski", flag: "🇵🇱" },
{ code: "en", label: "English", flag: "🇬🇧" },
{ code: "de", label: "Deutsch", flag: "🇩🇪" },
];
}
export function getLang() {
return currentLang;
}
export function isLangBusy() {
return langBusy;
}
export function t(key, vars = {}) {
const catalog = catalogs[currentLang] || catalogs.pl;
const fallback = catalogs.pl;
const value = catalog[key] ?? fallback[key] ?? key;
return interpolate(value, vars);
}
export function applyTranslations(root = document) {
root.querySelectorAll("[data-i18n]").forEach((el) => {
const key = el.getAttribute("data-i18n");
if (!key) return;
const html = el.hasAttribute("data-i18n-html");
const text = t(key);
if (html) {
el.innerHTML = text;
} else {
el.textContent = text;
}
});
root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
const key = el.getAttribute("data-i18n-placeholder");
if (!key) return;
el.setAttribute("placeholder", t(key));
});
root.querySelectorAll("[data-i18n-aria]").forEach((el) => {
const key = el.getAttribute("data-i18n-aria");
if (!key) return;
el.setAttribute("aria-label", t(key));
});
document.documentElement.lang = t("html.lang");
document.title = t("doc.title");
}
export function onLangChange(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
}
export function onLangBusyChange(callback) {
busyListeners.add(callback);
return () => busyListeners.delete(callback);
}
export async function setLang(lang, { persist = true, notify = true } = {}) {
const next = catalogs[lang] ? lang : "pl";
const changed = next !== currentLang;
currentLang = next;
if (persist) {
try {
localStorage.setItem(STORAGE_KEY, currentLang);
} catch {
// ignore
}
}
applyTranslations();
if (!(notify && changed)) {
return currentLang;
}
if (langBusy) {
return currentLang;
}
const startedAt = Date.now();
setLangBusy(true);
try {
const tasks = [];
listeners.forEach((cb) => {
try {
const result = cb(currentLang);
if (result != null && typeof result.then === "function") {
tasks.push(result);
}
} catch (err) {
console.warn("[i18n] listener failed", err);
}
});
if (tasks.length) {
await Promise.allSettled(tasks);
}
} finally {
const wait = Math.max(0, LANG_BUSY_MIN_MS - (Date.now() - startedAt));
if (wait > 0) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
setLangBusy(false);
}
return currentLang;
}
export function initI18n() {
let stored = "";
try {
stored = localStorage.getItem(STORAGE_KEY) || "";
} catch {
stored = "";
}
const initial = catalogs[stored] ? stored : "pl";
return setLang(initial, { persist: true, notify: false });
}
function flagImgHtml(code) {
const safe = String(code || "pl").toLowerCase().replace(/[^a-z]/g, "") || "pl";
return `<img class="lang-flag-img" src="assets/img/flags/${safe}.svg" alt="" width="28" height="20" decoding="async">`;
}
function spinnerHtml() {
return `<span class="lang-picker-spinner" aria-hidden="true"></span>`;
}
export function createLanguagePicker({ idPrefix = "lang" } = {}) {
ensureDocumentClickHandler();
const wrap = document.createElement("div");
wrap.className = "lang-picker";
wrap.dataset.langPicker = "1";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "lang-picker-btn";
btn.id = `${idPrefix}Btn`;
btn.setAttribute("aria-haspopup", "listbox");
btn.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.className = "lang-picker-menu hidden";
menu.id = `${idPrefix}Menu`;
menu.setAttribute("role", "listbox");
const pickerApi = {
el: wrap,
close: () => {
menu.classList.add("hidden");
btn.setAttribute("aria-expanded", "false");
wrap.classList.remove("is-open");
openPickerClosers.delete(pickerApi);
},
};
function paintButton() {
const langs = getAvailableLanguages();
const current = langs.find((l) => l.code === getLang()) || langs[0];
const code = current?.code || "pl";
if (langBusy) {
btn.innerHTML = spinnerHtml();
btn.disabled = true;
btn.setAttribute("aria-busy", "true");
btn.setAttribute("aria-label", t("lang.loading"));
wrap.classList.add("is-loading");
return;
}
btn.disabled = false;
btn.setAttribute("aria-busy", "false");
btn.innerHTML = flagImgHtml(code);
btn.setAttribute("aria-label", `${t("lang.aria")}: ${current?.label || code}`);
wrap.classList.remove("is-loading");
}
function refresh() {
paintButton();
const langs = getAvailableLanguages();
menu.innerHTML = "";
langs.forEach((lang) => {
const option = document.createElement("button");
option.type = "button";
option.className = "lang-picker-option" + (lang.code === getLang() ? " is-active" : "");
option.setAttribute("role", "option");
option.setAttribute("aria-selected", lang.code === getLang() ? "true" : "false");
option.disabled = langBusy;
option.innerHTML = `${flagImgHtml(lang.code)}<span>${lang.label}</span>`;
option.addEventListener("click", (e) => {
e.stopPropagation();
if (langBusy) return;
pickerApi.close();
if (lang.code !== getLang()) {
void setLang(lang.code);
}
});
menu.appendChild(option);
});
}
function open() {
if (langBusy) return;
openPickerClosers.forEach((p) => {
if (p !== pickerApi) p.close();
});
menu.classList.remove("hidden");
btn.setAttribute("aria-expanded", "true");
wrap.classList.add("is-open");
openPickerClosers.add(pickerApi);
}
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (langBusy) return;
if (menu.classList.contains("hidden")) open();
else pickerApi.close();
});
wrap.appendChild(btn);
wrap.appendChild(menu);
refresh();
onLangChange(() => refresh());
onLangBusyChange(() => {
if (langBusy) pickerApi.close();
paintButton();
});
return { el: wrap, refresh, close: pickerApi.close };
}