feat: implement internationalization for duty calendar
All checks were successful
CI / lint-and-test (push) Successful in 14s
All checks were successful
CI / lint-and-test (push) Successful in 14s
- Introduced a new `i18n.js` module for handling translations and language detection, supporting both Russian and English. - Updated various components to utilize the new translation functions, enhancing user experience by providing localized messages for errors, hints, and UI elements. - Refactored existing code in `api.js`, `calendar.js`, `dutyList.js`, `hints.js`, and `ui.js` to replace hardcoded strings with translated messages, improving maintainability and accessibility. - Enhanced the initialization process in `main.js` to set the document language and update UI elements based on the user's language preference.
This commit is contained in:
@@ -4,15 +4,18 @@
|
||||
|
||||
import { FETCH_TIMEOUT_MS } from "./constants.js";
|
||||
import { getInitData } from "./auth.js";
|
||||
import { state } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
/**
|
||||
* Build fetch options with init data header and timeout abort.
|
||||
* Build fetch options with init data header, Accept-Language and timeout abort.
|
||||
* @param {string} initData - Telegram init data
|
||||
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
|
||||
*/
|
||||
export function buildFetchOptions(initData) {
|
||||
const headers = {};
|
||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||
headers["Accept-Language"] = state.lang || "ru";
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
return { headers, signal: controller.signal, timeoutId };
|
||||
@@ -37,7 +40,7 @@ export async function fetchDuties(from, to) {
|
||||
try {
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
let detail = "Доступ запрещён";
|
||||
let detail = t(state.lang, "access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && body.detail !== undefined) {
|
||||
@@ -53,11 +56,11 @@ export async function fetchDuties(from, to) {
|
||||
err.serverDetail = detail;
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) throw new Error("Ошибка загрузки");
|
||||
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") {
|
||||
throw new Error("Не удалось загрузить данные. Проверьте интернет.");
|
||||
throw new Error(t(state.lang, "error_network"));
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Calendar grid and events-by-date mapping.
|
||||
*/
|
||||
|
||||
import { calendarEl, monthTitleEl } from "./dom.js";
|
||||
import { MONTHS } from "./constants.js";
|
||||
import { calendarEl, monthTitleEl, state } from "./dom.js";
|
||||
import { monthName, t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import {
|
||||
localDateString,
|
||||
@@ -116,6 +116,7 @@ export function renderCalendar(
|
||||
d.getDate() +
|
||||
'</span><div class="day-markers">';
|
||||
if (showMarkers) {
|
||||
const lang = state.lang;
|
||||
if (dutyList.length) {
|
||||
html +=
|
||||
'<button type="button" class="duty-marker" data-event-type="duty" data-date="' +
|
||||
@@ -124,23 +125,23 @@ export function renderCalendar(
|
||||
namesAttr(dutyList) +
|
||||
'" data-duty-items=\'' +
|
||||
dutyItemsJson +
|
||||
"' aria-label=\"Дежурные\">Д</button>";
|
||||
"' aria-label=\"" + escapeHtml(t(lang, "aria.duty")) + "\">Д</button>";
|
||||
}
|
||||
if (unavailableList.length) {
|
||||
html +=
|
||||
'<button type="button" class="unavailable-marker" data-event-type="unavailable" data-names="' +
|
||||
namesAttr(unavailableList) +
|
||||
'" aria-label="Недоступен">Н</button>';
|
||||
'" aria-label="' + escapeHtml(t(lang, "aria.unavailable")) + '">Н</button>';
|
||||
}
|
||||
if (vacationList.length) {
|
||||
html +=
|
||||
'<button type="button" class="vacation-marker" data-event-type="vacation" data-names="' +
|
||||
namesAttr(vacationList) +
|
||||
'" aria-label="Отпуск">О</button>';
|
||||
'" aria-label="' + escapeHtml(t(lang, "aria.vacation")) + '">О</button>';
|
||||
}
|
||||
if (hasEvent) {
|
||||
html +=
|
||||
'<button type="button" class="info-btn" aria-label="Информация о дне" data-summary="' +
|
||||
'<button type="button" class="info-btn" aria-label="' + escapeHtml(t(lang, "aria.day_info")) + '" data-summary="' +
|
||||
escapeHtml(eventSummaries.join("\n")) +
|
||||
'">i</button>';
|
||||
}
|
||||
@@ -151,7 +152,7 @@ export function renderCalendar(
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
monthTitleEl.textContent = MONTHS[month] + " " + year;
|
||||
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
|
||||
bindInfoButtonTooltips();
|
||||
bindDutyMarkerTooltips();
|
||||
}
|
||||
|
||||
@@ -7,14 +7,3 @@ export const RETRY_DELAY_MS = 800;
|
||||
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
||||
|
||||
export const THEME_BG = { dark: "#17212b", light: "#d5d6db" };
|
||||
|
||||
export const MONTHS = [
|
||||
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
|
||||
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь"
|
||||
];
|
||||
|
||||
export const EVENT_TYPE_LABELS = {
|
||||
duty: "Дежурство",
|
||||
unavailable: "Недоступен",
|
||||
vacation: "Отпуск"
|
||||
};
|
||||
|
||||
@@ -39,5 +39,7 @@ export const state = {
|
||||
/** @type {object[]} */
|
||||
lastDutiesForList: [],
|
||||
/** @type {ReturnType<typeof setInterval>|null} */
|
||||
todayRefreshInterval: null
|
||||
todayRefreshInterval: null,
|
||||
/** @type {'ru'|'en'} */
|
||||
lang: "ru"
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { dutyListEl, state } from "./dom.js";
|
||||
import { EVENT_TYPE_LABELS } from "./constants.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import {
|
||||
localDateString,
|
||||
@@ -33,9 +33,10 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
||||
} else {
|
||||
timeStr = startDDMM + " " + startTime + " – " + endDDMM + " " + endTime;
|
||||
}
|
||||
const lang = state.lang;
|
||||
const typeLabel = isCurrent
|
||||
? "Сейчас дежурит"
|
||||
: (EVENT_TYPE_LABELS[d.event_type] || "Дежурство");
|
||||
? t(lang, "duty.now_on_duty")
|
||||
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||||
const extraClass = isCurrent ? " duty-item--current" : "";
|
||||
return (
|
||||
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
||||
@@ -59,15 +60,16 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
||||
const lang = state.lang;
|
||||
const typeLabel =
|
||||
typeLabelOverride != null
|
||||
? typeLabelOverride
|
||||
: (EVENT_TYPE_LABELS[d.event_type] || d.event_type);
|
||||
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||||
let itemClass = "duty-item duty-item--" + (d.event_type || "duty");
|
||||
if (extraClass) itemClass += " " + extraClass;
|
||||
let timeOrRange = "";
|
||||
if (showUntilEnd && d.event_type === "duty") {
|
||||
timeOrRange = "до " + formatTimeLocal(d.end_at);
|
||||
timeOrRange = t(lang, "duty.until", { time: formatTimeLocal(d.end_at) });
|
||||
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
|
||||
const startStr = formatDateKey(d.start_at);
|
||||
const endStr = formatDateKey(d.end_at);
|
||||
@@ -98,7 +100,7 @@ export function renderDutyList(duties) {
|
||||
const filtered = duties.filter((d) => d.event_type === "duty");
|
||||
if (filtered.length === 0) {
|
||||
dutyListEl.classList.remove("duty-timeline");
|
||||
dutyListEl.innerHTML = '<p class="muted">В этом месяце дежурств нет.</p>';
|
||||
dutyListEl.innerHTML = '<p class="muted">' + t(state.lang, "duty.none_this_month") + "</p>";
|
||||
return;
|
||||
}
|
||||
dutyListEl.classList.add("duty-timeline");
|
||||
@@ -121,7 +123,9 @@ export function renderDutyList(duties) {
|
||||
"duty-timeline-day" + (isToday ? " duty-timeline-day--today" : "");
|
||||
const dateLabel = dateKeyToDDMM(date);
|
||||
const dateCellHtml = isToday
|
||||
? '<span class="duty-timeline-date"><span class="duty-timeline-date-label">Сегодня</span><span class="duty-timeline-date-day">' +
|
||||
? '<span class="duty-timeline-date"><span class="duty-timeline-date-label">' +
|
||||
escapeHtml(t(state.lang, "duty.today")) +
|
||||
'</span><span class="duty-timeline-date-day">' +
|
||||
escapeHtml(dateLabel) +
|
||||
'</span><span class="duty-timeline-date-dot" aria-hidden="true"></span></span>'
|
||||
: '<span class="duty-timeline-date">' + escapeHtml(dateLabel) + "</span>";
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Tooltips for calendar info buttons and duty markers.
|
||||
*/
|
||||
|
||||
import { calendarEl } from "./dom.js";
|
||||
import { EVENT_TYPE_LABELS } from "./constants.js";
|
||||
import { calendarEl, state } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { localDateString, formatHHMM } from "./dateUtils.js";
|
||||
|
||||
@@ -90,10 +90,12 @@ function getItemFullName(item) {
|
||||
* @param {number} idx - index in dutyItems
|
||||
* @param {number} total - dutyItems.length
|
||||
* @param {string} hintDay - YYYY-MM-DD
|
||||
* @param {string} sep - separator after "с"/"до" (e.g. " - " for text, "\u00a0" for HTML)
|
||||
* @param {string} sep - separator after "from"/"to" (e.g. " - " for text, "\u00a0" for HTML)
|
||||
* @param {string} fromLabel - translated "from" prefix
|
||||
* @param {string} toLabel - translated "to" prefix
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
|
||||
function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLabel) {
|
||||
const startAt = item.start_at != null ? item.start_at : item.startAt;
|
||||
const endAt = item.end_at != null ? item.end_at : item.endAt;
|
||||
const endHHMM = endAt ? formatHHMM(endAt) : "";
|
||||
@@ -105,17 +107,17 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
|
||||
let timePrefix = "";
|
||||
if (idx === 0) {
|
||||
if (total === 1 && startSameDay && startHHMM) {
|
||||
timePrefix = "с" + sep + startHHMM;
|
||||
timePrefix = fromLabel + sep + startHHMM;
|
||||
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||||
timePrefix += " до" + sep + endHHMM;
|
||||
timePrefix += " " + toLabel + sep + endHHMM;
|
||||
}
|
||||
} else if (endHHMM) {
|
||||
timePrefix = "до" + sep + endHHMM;
|
||||
timePrefix = toLabel + sep + endHHMM;
|
||||
}
|
||||
} else if (idx > 0) {
|
||||
if (startHHMM) timePrefix = "с" + sep + startHHMM;
|
||||
if (startHHMM) timePrefix = fromLabel + sep + startHHMM;
|
||||
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
||||
timePrefix += (timePrefix ? " " : "") + "до" + sep + endHHMM;
|
||||
timePrefix += (timePrefix ? " " : "") + toLabel + sep + endHHMM;
|
||||
}
|
||||
}
|
||||
return timePrefix;
|
||||
@@ -126,16 +128,20 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
|
||||
* @param {object[]} dutyItems
|
||||
* @param {string} hintDay
|
||||
* @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML
|
||||
* @param {string} fromLabel
|
||||
* @param {string} toLabel
|
||||
* @returns {{ timePrefix: string, fullName: string }[]}
|
||||
*/
|
||||
function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
|
||||
function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
|
||||
return dutyItems.map((item, idx) => {
|
||||
const timePrefix = buildDutyItemTimePrefix(
|
||||
item,
|
||||
idx,
|
||||
dutyItems.length,
|
||||
hintDay,
|
||||
timeSep
|
||||
timeSep,
|
||||
fromLabel,
|
||||
toLabel
|
||||
);
|
||||
const fullName = getItemFullName(item);
|
||||
return { timePrefix, fullName };
|
||||
@@ -148,14 +154,17 @@ function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDutyMarkerHintContent(marker) {
|
||||
const lang = state.lang;
|
||||
const type = marker.getAttribute("data-event-type") || "duty";
|
||||
const label = EVENT_TYPE_LABELS[type] || type;
|
||||
const label = t(lang, "event_type." + type);
|
||||
const names = marker.getAttribute("data-names") || "";
|
||||
const fromLabel = t(lang, "hint.from");
|
||||
const toLabel = t(lang, "hint.to");
|
||||
let body;
|
||||
if (type === "duty") {
|
||||
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
||||
if (dutyItems.length >= 1 && hasTimes) {
|
||||
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ");
|
||||
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ", fromLabel, toLabel);
|
||||
body = rows
|
||||
.map((r) =>
|
||||
r.timePrefix ? r.timePrefix + " - " + r.fullName : r.fullName
|
||||
@@ -176,14 +185,17 @@ export function getDutyMarkerHintContent(marker) {
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getDutyMarkerHintHtml(marker) {
|
||||
const lang = state.lang;
|
||||
const type = marker.getAttribute("data-event-type") || "duty";
|
||||
if (type !== "duty") return null;
|
||||
const names = marker.getAttribute("data-names") || "";
|
||||
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
||||
const nbsp = "\u00a0";
|
||||
const fromLabel = t(lang, "hint.from");
|
||||
const toLabel = t(lang, "hint.to");
|
||||
let rowHtmls;
|
||||
if (dutyItems.length >= 1 && hasTimes) {
|
||||
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp);
|
||||
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp, fromLabel, toLabel);
|
||||
rowHtmls = rows.map((r) => {
|
||||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : "";
|
||||
const sepHtml = r.timePrefix ? '<span class="calendar-event-hint-sep">-</span>' : '<span class="calendar-event-hint-sep"></span>';
|
||||
@@ -210,7 +222,7 @@ export function getDutyMarkerHintHtml(marker) {
|
||||
}
|
||||
if (rowHtmls.length === 0) return null;
|
||||
return (
|
||||
'<div class="calendar-event-hint-title">Дежурство:</div><div class="calendar-event-hint-rows">' +
|
||||
'<div class="calendar-event-hint-title">' + escapeHtml(t(lang, "hint.duty_title")) + '</div><div class="calendar-event-hint-rows">' +
|
||||
rowHtmls
|
||||
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
|
||||
.join("") +
|
||||
@@ -244,11 +256,12 @@ export function bindInfoButtonTooltips() {
|
||||
document.body.appendChild(hintEl);
|
||||
}
|
||||
if (!calendarEl) return;
|
||||
const lang = state.lang;
|
||||
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const summary = btn.getAttribute("data-summary") || "";
|
||||
const content = "События:\n" + summary;
|
||||
const content = t(lang, "hint.events") + "\n" + summary;
|
||||
if (hintEl.hidden || hintEl.textContent !== content) {
|
||||
hintEl.textContent = content;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
|
||||
180
webapp/js/i18n.js
Normal file
180
webapp/js/i18n.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Internationalization: language detection and translations for webapp.
|
||||
*/
|
||||
|
||||
import { getInitData } from "./auth.js";
|
||||
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
export const MESSAGES = {
|
||||
en: {
|
||||
"app.title": "Duty Calendar",
|
||||
loading: "Loading…",
|
||||
access_denied: "Access denied.",
|
||||
error_load_failed: "Load failed",
|
||||
error_network: "Could not load data. Check your connection.",
|
||||
error_generic: "Could not load data.",
|
||||
"nav.prev_month": "Previous month",
|
||||
"nav.next_month": "Next month",
|
||||
"weekdays.mon": "Mon",
|
||||
"weekdays.tue": "Tue",
|
||||
"weekdays.wed": "Wed",
|
||||
"weekdays.thu": "Thu",
|
||||
"weekdays.fri": "Fri",
|
||||
"weekdays.sat": "Sat",
|
||||
"weekdays.sun": "Sun",
|
||||
"month.jan": "January",
|
||||
"month.feb": "February",
|
||||
"month.mar": "March",
|
||||
"month.apr": "April",
|
||||
"month.may": "May",
|
||||
"month.jun": "June",
|
||||
"month.jul": "July",
|
||||
"month.aug": "August",
|
||||
"month.sep": "September",
|
||||
"month.oct": "October",
|
||||
"month.nov": "November",
|
||||
"month.dec": "December",
|
||||
"aria.duty": "On duty",
|
||||
"aria.unavailable": "Unavailable",
|
||||
"aria.vacation": "Vacation",
|
||||
"aria.day_info": "Day info",
|
||||
"event_type.duty": "Duty",
|
||||
"event_type.unavailable": "Unavailable",
|
||||
"event_type.vacation": "Vacation",
|
||||
"duty.now_on_duty": "On duty now",
|
||||
"duty.none_this_month": "No duties this month.",
|
||||
"duty.today": "Today",
|
||||
"duty.until": "until {time}",
|
||||
"hint.from": "from",
|
||||
"hint.to": "until",
|
||||
"hint.duty_title": "Duty:",
|
||||
"hint.events": "Events:"
|
||||
},
|
||||
ru: {
|
||||
"app.title": "Календарь дежурств",
|
||||
loading: "Загрузка…",
|
||||
access_denied: "Доступ запрещён.",
|
||||
error_load_failed: "Ошибка загрузки",
|
||||
error_network: "Не удалось загрузить данные. Проверьте интернет.",
|
||||
error_generic: "Не удалось загрузить данные.",
|
||||
"nav.prev_month": "Предыдущий месяц",
|
||||
"nav.next_month": "Следующий месяц",
|
||||
"weekdays.mon": "Пн",
|
||||
"weekdays.tue": "Вт",
|
||||
"weekdays.wed": "Ср",
|
||||
"weekdays.thu": "Чт",
|
||||
"weekdays.fri": "Пт",
|
||||
"weekdays.sat": "Сб",
|
||||
"weekdays.sun": "Вс",
|
||||
"month.jan": "Январь",
|
||||
"month.feb": "Февраль",
|
||||
"month.mar": "Март",
|
||||
"month.apr": "Апрель",
|
||||
"month.may": "Май",
|
||||
"month.jun": "Июнь",
|
||||
"month.jul": "Июль",
|
||||
"month.aug": "Август",
|
||||
"month.sep": "Сентябрь",
|
||||
"month.oct": "Октябрь",
|
||||
"month.nov": "Ноябрь",
|
||||
"month.dec": "Декабрь",
|
||||
"aria.duty": "Дежурные",
|
||||
"aria.unavailable": "Недоступен",
|
||||
"aria.vacation": "Отпуск",
|
||||
"aria.day_info": "Информация о дне",
|
||||
"event_type.duty": "Дежурство",
|
||||
"event_type.unavailable": "Недоступен",
|
||||
"event_type.vacation": "Отпуск",
|
||||
"duty.now_on_duty": "Сейчас дежурит",
|
||||
"duty.none_this_month": "В этом месяце дежурств нет.",
|
||||
"duty.today": "Сегодня",
|
||||
"duty.until": "до {time}",
|
||||
"hint.from": "с",
|
||||
"hint.to": "до",
|
||||
"hint.duty_title": "Дежурство:",
|
||||
"hint.events": "События:"
|
||||
}
|
||||
};
|
||||
|
||||
const MONTH_KEYS = [
|
||||
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun",
|
||||
"month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec"
|
||||
];
|
||||
|
||||
const WEEKDAY_KEYS = [
|
||||
"weekdays.mon", "weekdays.tue", "weekdays.wed", "weekdays.thu",
|
||||
"weekdays.fri", "weekdays.sat", "weekdays.sun"
|
||||
];
|
||||
|
||||
/**
|
||||
* Normalize language code to 'ru' or 'en'.
|
||||
* @param {string} code - e.g. 'ru', 'en', 'uk'
|
||||
* @returns {'ru'|'en'}
|
||||
*/
|
||||
function normalizeLang(code) {
|
||||
if (!code || typeof code !== "string") return "ru";
|
||||
const lower = code.toLowerCase();
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
return "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language: Telegram initData user.language_code → navigator → fallback 'ru'.
|
||||
* @returns {'ru'|'en'}
|
||||
*/
|
||||
export function getLang() {
|
||||
const initData = getInitData();
|
||||
if (initData) {
|
||||
try {
|
||||
const params = new URLSearchParams(initData);
|
||||
const userStr = params.get("user");
|
||||
if (userStr) {
|
||||
const user = JSON.parse(decodeURIComponent(userStr));
|
||||
const code = user && user.language_code;
|
||||
if (code) return normalizeLang(code);
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const nav = navigator.language || (navigator.languages && navigator.languages[0]) || "";
|
||||
return normalizeLang(nav);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated string; fallback to en if key missing in lang. Supports {placeholder}.
|
||||
* @param {'ru'|'en'} lang
|
||||
* @param {string} key - e.g. 'app.title', 'duty.until'
|
||||
* @param {Record<string, string>} [params] - e.g. { time: '14:00' }
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(lang, key, params = {}) {
|
||||
const dict = MESSAGES[lang] || MESSAGES.en;
|
||||
let s = dict[key];
|
||||
if (s === undefined) s = MESSAGES.en[key];
|
||||
if (s === undefined) return key;
|
||||
Object.keys(params).forEach((k) => {
|
||||
s = s.replace(new RegExp("\\{" + k + "\\}", "g"), params[k]);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get month name by 0-based index.
|
||||
* @param {'ru'|'en'} lang
|
||||
* @param {number} month - 0–11
|
||||
* @returns {string}
|
||||
*/
|
||||
export function monthName(lang, month) {
|
||||
const key = MONTH_KEYS[month];
|
||||
return key ? t(lang, key) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekday short labels (Mon–Sun order) for given lang.
|
||||
* @param {'ru'|'en'} lang
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function weekdayLabels(lang) {
|
||||
return WEEKDAY_KEYS.map((k) => t(lang, k));
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { initTheme, applyTheme } from "./theme.js";
|
||||
import { getLang, t, weekdayLabels } from "./i18n.js";
|
||||
import { getInitData } from "./auth.js";
|
||||
import { isLocalhost } from "./auth.js";
|
||||
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
prevBtn,
|
||||
nextBtn,
|
||||
loadingEl,
|
||||
errorEl
|
||||
errorEl,
|
||||
weekdaysEl
|
||||
} from "./dom.js";
|
||||
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
|
||||
import { fetchDuties, fetchCalendarEvents } from "./api.js";
|
||||
@@ -32,6 +34,20 @@ import {
|
||||
|
||||
initTheme();
|
||||
|
||||
state.lang = getLang();
|
||||
document.documentElement.lang = state.lang;
|
||||
document.title = t(state.lang, "app.title");
|
||||
if (loadingEl) loadingEl.textContent = t(state.lang, "loading");
|
||||
const dayLabels = weekdayLabels(state.lang);
|
||||
if (weekdaysEl) {
|
||||
const spans = weekdaysEl.querySelectorAll("span");
|
||||
spans.forEach((span, i) => {
|
||||
if (dayLabels[i]) span.textContent = dayLabels[i];
|
||||
});
|
||||
}
|
||||
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
|
||||
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
|
||||
|
||||
/**
|
||||
* Run callback when Telegram WebApp is ready (or immediately outside Telegram).
|
||||
* Expands and applies theme when in TWA.
|
||||
@@ -79,12 +95,12 @@ function requireTelegramOrLocalhost(onAllowed) {
|
||||
onAllowed();
|
||||
return;
|
||||
}
|
||||
showAccessDenied();
|
||||
showAccessDenied(undefined);
|
||||
if (loadingEl) loadingEl.classList.add("hidden");
|
||||
}, RETRY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
showAccessDenied();
|
||||
showAccessDenied(undefined);
|
||||
if (loadingEl) loadingEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
@@ -151,7 +167,7 @@ async function loadMonth() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
showError(e.message || "Не удалось загрузить данные.");
|
||||
showError(e.message || t(state.lang, "error_generic"));
|
||||
setNavEnabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
state,
|
||||
calendarEl,
|
||||
dutyListEl,
|
||||
loadingEl,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
prevBtn,
|
||||
nextBtn
|
||||
} from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
/**
|
||||
* Show access-denied view and hide calendar/list/loading/error.
|
||||
@@ -26,7 +28,8 @@ export function showAccessDenied(serverDetail) {
|
||||
if (loadingEl) loadingEl.classList.add("hidden");
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
if (accessDeniedEl) {
|
||||
accessDeniedEl.innerHTML = "<p>Доступ запрещён.</p>";
|
||||
const msg = t(state.lang, "access_denied");
|
||||
accessDeniedEl.innerHTML = "<p>" + msg + "</p>";
|
||||
if (serverDetail && serverDetail.trim()) {
|
||||
const detail = document.createElement("p");
|
||||
detail.className = "access-denied-detail";
|
||||
|
||||
Reference in New Issue
Block a user