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 { FETCH_TIMEOUT_MS } from "./constants.js";
|
||||||
import { getInitData } from "./auth.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
|
* @param {string} initData - Telegram init data
|
||||||
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
|
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
|
||||||
*/
|
*/
|
||||||
export function buildFetchOptions(initData) {
|
export function buildFetchOptions(initData) {
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||||
|
headers["Accept-Language"] = state.lang || "ru";
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
return { headers, signal: controller.signal, timeoutId };
|
return { headers, signal: controller.signal, timeoutId };
|
||||||
@@ -37,7 +40,7 @@ export async function fetchDuties(from, to) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
let detail = "Доступ запрещён";
|
let detail = t(state.lang, "access_denied");
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body && body.detail !== undefined) {
|
if (body && body.detail !== undefined) {
|
||||||
@@ -53,11 +56,11 @@ export async function fetchDuties(from, to) {
|
|||||||
err.serverDetail = detail;
|
err.serverDetail = detail;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new Error("Ошибка загрузки");
|
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
|
||||||
return res.json();
|
return res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === "AbortError") {
|
if (e.name === "AbortError") {
|
||||||
throw new Error("Не удалось загрузить данные. Проверьте интернет.");
|
throw new Error(t(state.lang, "error_network"));
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Calendar grid and events-by-date mapping.
|
* Calendar grid and events-by-date mapping.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { calendarEl, monthTitleEl } from "./dom.js";
|
import { calendarEl, monthTitleEl, state } from "./dom.js";
|
||||||
import { MONTHS } from "./constants.js";
|
import { monthName, t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
import {
|
import {
|
||||||
localDateString,
|
localDateString,
|
||||||
@@ -116,6 +116,7 @@ export function renderCalendar(
|
|||||||
d.getDate() +
|
d.getDate() +
|
||||||
'</span><div class="day-markers">';
|
'</span><div class="day-markers">';
|
||||||
if (showMarkers) {
|
if (showMarkers) {
|
||||||
|
const lang = state.lang;
|
||||||
if (dutyList.length) {
|
if (dutyList.length) {
|
||||||
html +=
|
html +=
|
||||||
'<button type="button" class="duty-marker" data-event-type="duty" data-date="' +
|
'<button type="button" class="duty-marker" data-event-type="duty" data-date="' +
|
||||||
@@ -124,23 +125,23 @@ export function renderCalendar(
|
|||||||
namesAttr(dutyList) +
|
namesAttr(dutyList) +
|
||||||
'" data-duty-items=\'' +
|
'" data-duty-items=\'' +
|
||||||
dutyItemsJson +
|
dutyItemsJson +
|
||||||
"' aria-label=\"Дежурные\">Д</button>";
|
"' aria-label=\"" + escapeHtml(t(lang, "aria.duty")) + "\">Д</button>";
|
||||||
}
|
}
|
||||||
if (unavailableList.length) {
|
if (unavailableList.length) {
|
||||||
html +=
|
html +=
|
||||||
'<button type="button" class="unavailable-marker" data-event-type="unavailable" data-names="' +
|
'<button type="button" class="unavailable-marker" data-event-type="unavailable" data-names="' +
|
||||||
namesAttr(unavailableList) +
|
namesAttr(unavailableList) +
|
||||||
'" aria-label="Недоступен">Н</button>';
|
'" aria-label="' + escapeHtml(t(lang, "aria.unavailable")) + '">Н</button>';
|
||||||
}
|
}
|
||||||
if (vacationList.length) {
|
if (vacationList.length) {
|
||||||
html +=
|
html +=
|
||||||
'<button type="button" class="vacation-marker" data-event-type="vacation" data-names="' +
|
'<button type="button" class="vacation-marker" data-event-type="vacation" data-names="' +
|
||||||
namesAttr(vacationList) +
|
namesAttr(vacationList) +
|
||||||
'" aria-label="Отпуск">О</button>';
|
'" aria-label="' + escapeHtml(t(lang, "aria.vacation")) + '">О</button>';
|
||||||
}
|
}
|
||||||
if (hasEvent) {
|
if (hasEvent) {
|
||||||
html +=
|
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")) +
|
escapeHtml(eventSummaries.join("\n")) +
|
||||||
'">i</button>';
|
'">i</button>';
|
||||||
}
|
}
|
||||||
@@ -151,7 +152,7 @@ export function renderCalendar(
|
|||||||
d.setDate(d.getDate() + 1);
|
d.setDate(d.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
monthTitleEl.textContent = MONTHS[month] + " " + year;
|
monthTitleEl.textContent = monthName(state.lang, month) + " " + year;
|
||||||
bindInfoButtonTooltips();
|
bindInfoButtonTooltips();
|
||||||
bindDutyMarkerTooltips();
|
bindDutyMarkerTooltips();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,3 @@ export const RETRY_DELAY_MS = 800;
|
|||||||
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
||||||
|
|
||||||
export const THEME_BG = { dark: "#17212b", light: "#d5d6db" };
|
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[]} */
|
/** @type {object[]} */
|
||||||
lastDutiesForList: [],
|
lastDutiesForList: [],
|
||||||
/** @type {ReturnType<typeof setInterval>|null} */
|
/** @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 { dutyListEl, state } from "./dom.js";
|
||||||
import { EVENT_TYPE_LABELS } from "./constants.js";
|
import { t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
import {
|
import {
|
||||||
localDateString,
|
localDateString,
|
||||||
@@ -33,9 +33,10 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
|||||||
} else {
|
} else {
|
||||||
timeStr = startDDMM + " " + startTime + " – " + endDDMM + " " + endTime;
|
timeStr = startDDMM + " " + startTime + " – " + endDDMM + " " + endTime;
|
||||||
}
|
}
|
||||||
|
const lang = state.lang;
|
||||||
const typeLabel = isCurrent
|
const typeLabel = isCurrent
|
||||||
? "Сейчас дежурит"
|
? t(lang, "duty.now_on_duty")
|
||||||
: (EVENT_TYPE_LABELS[d.event_type] || "Дежурство");
|
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||||||
const extraClass = isCurrent ? " duty-item--current" : "";
|
const extraClass = isCurrent ? " duty-item--current" : "";
|
||||||
return (
|
return (
|
||||||
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
||||||
@@ -59,15 +60,16 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
||||||
|
const lang = state.lang;
|
||||||
const typeLabel =
|
const typeLabel =
|
||||||
typeLabelOverride != null
|
typeLabelOverride != null
|
||||||
? typeLabelOverride
|
? 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");
|
let itemClass = "duty-item duty-item--" + (d.event_type || "duty");
|
||||||
if (extraClass) itemClass += " " + extraClass;
|
if (extraClass) itemClass += " " + extraClass;
|
||||||
let timeOrRange = "";
|
let timeOrRange = "";
|
||||||
if (showUntilEnd && d.event_type === "duty") {
|
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") {
|
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
|
||||||
const startStr = formatDateKey(d.start_at);
|
const startStr = formatDateKey(d.start_at);
|
||||||
const endStr = formatDateKey(d.end_at);
|
const endStr = formatDateKey(d.end_at);
|
||||||
@@ -98,7 +100,7 @@ export function renderDutyList(duties) {
|
|||||||
const filtered = duties.filter((d) => d.event_type === "duty");
|
const filtered = duties.filter((d) => d.event_type === "duty");
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
dutyListEl.classList.remove("duty-timeline");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
dutyListEl.classList.add("duty-timeline");
|
dutyListEl.classList.add("duty-timeline");
|
||||||
@@ -121,7 +123,9 @@ export function renderDutyList(duties) {
|
|||||||
"duty-timeline-day" + (isToday ? " duty-timeline-day--today" : "");
|
"duty-timeline-day" + (isToday ? " duty-timeline-day--today" : "");
|
||||||
const dateLabel = dateKeyToDDMM(date);
|
const dateLabel = dateKeyToDDMM(date);
|
||||||
const dateCellHtml = isToday
|
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) +
|
escapeHtml(dateLabel) +
|
||||||
'</span><span class="duty-timeline-date-dot" aria-hidden="true"></span></span>'
|
'</span><span class="duty-timeline-date-dot" aria-hidden="true"></span></span>'
|
||||||
: '<span class="duty-timeline-date">' + escapeHtml(dateLabel) + "</span>";
|
: '<span class="duty-timeline-date">' + escapeHtml(dateLabel) + "</span>";
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Tooltips for calendar info buttons and duty markers.
|
* Tooltips for calendar info buttons and duty markers.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { calendarEl } from "./dom.js";
|
import { calendarEl, state } from "./dom.js";
|
||||||
import { EVENT_TYPE_LABELS } from "./constants.js";
|
import { t } from "./i18n.js";
|
||||||
import { escapeHtml } from "./utils.js";
|
import { escapeHtml } from "./utils.js";
|
||||||
import { localDateString, formatHHMM } from "./dateUtils.js";
|
import { localDateString, formatHHMM } from "./dateUtils.js";
|
||||||
|
|
||||||
@@ -90,10 +90,12 @@ function getItemFullName(item) {
|
|||||||
* @param {number} idx - index in dutyItems
|
* @param {number} idx - index in dutyItems
|
||||||
* @param {number} total - dutyItems.length
|
* @param {number} total - dutyItems.length
|
||||||
* @param {string} hintDay - YYYY-MM-DD
|
* @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}
|
* @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 startAt = item.start_at != null ? item.start_at : item.startAt;
|
||||||
const endAt = item.end_at != null ? item.end_at : item.endAt;
|
const endAt = item.end_at != null ? item.end_at : item.endAt;
|
||||||
const endHHMM = endAt ? formatHHMM(endAt) : "";
|
const endHHMM = endAt ? formatHHMM(endAt) : "";
|
||||||
@@ -105,17 +107,17 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
|
|||||||
let timePrefix = "";
|
let timePrefix = "";
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
if (total === 1 && startSameDay && startHHMM) {
|
if (total === 1 && startSameDay && startHHMM) {
|
||||||
timePrefix = "с" + sep + startHHMM;
|
timePrefix = fromLabel + sep + startHHMM;
|
||||||
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||||||
timePrefix += " до" + sep + endHHMM;
|
timePrefix += " " + toLabel + sep + endHHMM;
|
||||||
}
|
}
|
||||||
} else if (endHHMM) {
|
} else if (endHHMM) {
|
||||||
timePrefix = "до" + sep + endHHMM;
|
timePrefix = toLabel + sep + endHHMM;
|
||||||
}
|
}
|
||||||
} else if (idx > 0) {
|
} else if (idx > 0) {
|
||||||
if (startHHMM) timePrefix = "с" + sep + startHHMM;
|
if (startHHMM) timePrefix = fromLabel + sep + startHHMM;
|
||||||
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
||||||
timePrefix += (timePrefix ? " " : "") + "до" + sep + endHHMM;
|
timePrefix += (timePrefix ? " " : "") + toLabel + sep + endHHMM;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return timePrefix;
|
return timePrefix;
|
||||||
@@ -126,16 +128,20 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep) {
|
|||||||
* @param {object[]} dutyItems
|
* @param {object[]} dutyItems
|
||||||
* @param {string} hintDay
|
* @param {string} hintDay
|
||||||
* @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML
|
* @param {string} timeSep - e.g. " - " for text, "\u00a0" for HTML
|
||||||
|
* @param {string} fromLabel
|
||||||
|
* @param {string} toLabel
|
||||||
* @returns {{ timePrefix: string, fullName: string }[]}
|
* @returns {{ timePrefix: string, fullName: string }[]}
|
||||||
*/
|
*/
|
||||||
function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
|
function getDutyMarkerRows(dutyItems, hintDay, timeSep, fromLabel, toLabel) {
|
||||||
return dutyItems.map((item, idx) => {
|
return dutyItems.map((item, idx) => {
|
||||||
const timePrefix = buildDutyItemTimePrefix(
|
const timePrefix = buildDutyItemTimePrefix(
|
||||||
item,
|
item,
|
||||||
idx,
|
idx,
|
||||||
dutyItems.length,
|
dutyItems.length,
|
||||||
hintDay,
|
hintDay,
|
||||||
timeSep
|
timeSep,
|
||||||
|
fromLabel,
|
||||||
|
toLabel
|
||||||
);
|
);
|
||||||
const fullName = getItemFullName(item);
|
const fullName = getItemFullName(item);
|
||||||
return { timePrefix, fullName };
|
return { timePrefix, fullName };
|
||||||
@@ -148,14 +154,17 @@ function getDutyMarkerRows(dutyItems, hintDay, timeSep) {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getDutyMarkerHintContent(marker) {
|
export function getDutyMarkerHintContent(marker) {
|
||||||
|
const lang = state.lang;
|
||||||
const type = marker.getAttribute("data-event-type") || "duty";
|
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 names = marker.getAttribute("data-names") || "";
|
||||||
|
const fromLabel = t(lang, "hint.from");
|
||||||
|
const toLabel = t(lang, "hint.to");
|
||||||
let body;
|
let body;
|
||||||
if (type === "duty") {
|
if (type === "duty") {
|
||||||
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
||||||
if (dutyItems.length >= 1 && hasTimes) {
|
if (dutyItems.length >= 1 && hasTimes) {
|
||||||
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ");
|
const rows = getDutyMarkerRows(dutyItems, hintDay, " - ", fromLabel, toLabel);
|
||||||
body = rows
|
body = rows
|
||||||
.map((r) =>
|
.map((r) =>
|
||||||
r.timePrefix ? r.timePrefix + " - " + r.fullName : r.fullName
|
r.timePrefix ? r.timePrefix + " - " + r.fullName : r.fullName
|
||||||
@@ -176,14 +185,17 @@ export function getDutyMarkerHintContent(marker) {
|
|||||||
* @returns {string|null}
|
* @returns {string|null}
|
||||||
*/
|
*/
|
||||||
export function getDutyMarkerHintHtml(marker) {
|
export function getDutyMarkerHintHtml(marker) {
|
||||||
|
const lang = state.lang;
|
||||||
const type = marker.getAttribute("data-event-type") || "duty";
|
const type = marker.getAttribute("data-event-type") || "duty";
|
||||||
if (type !== "duty") return null;
|
if (type !== "duty") return null;
|
||||||
const names = marker.getAttribute("data-names") || "";
|
const names = marker.getAttribute("data-names") || "";
|
||||||
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
const { dutyItems, hasTimes, hintDay } = parseDutyMarkerData(marker);
|
||||||
const nbsp = "\u00a0";
|
const nbsp = "\u00a0";
|
||||||
|
const fromLabel = t(lang, "hint.from");
|
||||||
|
const toLabel = t(lang, "hint.to");
|
||||||
let rowHtmls;
|
let rowHtmls;
|
||||||
if (dutyItems.length >= 1 && hasTimes) {
|
if (dutyItems.length >= 1 && hasTimes) {
|
||||||
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp);
|
const rows = getDutyMarkerRows(dutyItems, hintDay, nbsp, fromLabel, toLabel);
|
||||||
rowHtmls = rows.map((r) => {
|
rowHtmls = rows.map((r) => {
|
||||||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) : "";
|
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>';
|
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;
|
if (rowHtmls.length === 0) return null;
|
||||||
return (
|
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
|
rowHtmls
|
||||||
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
|
.map((r) => '<div class="calendar-event-hint-row">' + r + "</div>")
|
||||||
.join("") +
|
.join("") +
|
||||||
@@ -244,11 +256,12 @@ export function bindInfoButtonTooltips() {
|
|||||||
document.body.appendChild(hintEl);
|
document.body.appendChild(hintEl);
|
||||||
}
|
}
|
||||||
if (!calendarEl) return;
|
if (!calendarEl) return;
|
||||||
|
const lang = state.lang;
|
||||||
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
|
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", (e) => {
|
btn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const summary = btn.getAttribute("data-summary") || "";
|
const summary = btn.getAttribute("data-summary") || "";
|
||||||
const content = "События:\n" + summary;
|
const content = t(lang, "hint.events") + "\n" + summary;
|
||||||
if (hintEl.hidden || hintEl.textContent !== content) {
|
if (hintEl.hidden || hintEl.textContent !== content) {
|
||||||
hintEl.textContent = content;
|
hintEl.textContent = content;
|
||||||
const rect = btn.getBoundingClientRect();
|
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 { initTheme, applyTheme } from "./theme.js";
|
||||||
|
import { getLang, t, weekdayLabels } from "./i18n.js";
|
||||||
import { getInitData } from "./auth.js";
|
import { getInitData } from "./auth.js";
|
||||||
import { isLocalhost } from "./auth.js";
|
import { isLocalhost } from "./auth.js";
|
||||||
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
|
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
|
||||||
@@ -12,7 +13,8 @@ import {
|
|||||||
prevBtn,
|
prevBtn,
|
||||||
nextBtn,
|
nextBtn,
|
||||||
loadingEl,
|
loadingEl,
|
||||||
errorEl
|
errorEl,
|
||||||
|
weekdaysEl
|
||||||
} from "./dom.js";
|
} from "./dom.js";
|
||||||
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
|
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
|
||||||
import { fetchDuties, fetchCalendarEvents } from "./api.js";
|
import { fetchDuties, fetchCalendarEvents } from "./api.js";
|
||||||
@@ -32,6 +34,20 @@ import {
|
|||||||
|
|
||||||
initTheme();
|
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).
|
* Run callback when Telegram WebApp is ready (or immediately outside Telegram).
|
||||||
* Expands and applies theme when in TWA.
|
* Expands and applies theme when in TWA.
|
||||||
@@ -79,12 +95,12 @@ function requireTelegramOrLocalhost(onAllowed) {
|
|||||||
onAllowed();
|
onAllowed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showAccessDenied();
|
showAccessDenied(undefined);
|
||||||
if (loadingEl) loadingEl.classList.add("hidden");
|
if (loadingEl) loadingEl.classList.add("hidden");
|
||||||
}, RETRY_DELAY_MS);
|
}, RETRY_DELAY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showAccessDenied();
|
showAccessDenied(undefined);
|
||||||
if (loadingEl) loadingEl.classList.add("hidden");
|
if (loadingEl) loadingEl.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +167,7 @@ async function loadMonth() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showError(e.message || "Не удалось загрузить данные.");
|
showError(e.message || t(state.lang, "error_generic"));
|
||||||
setNavEnabled(true);
|
setNavEnabled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
state,
|
||||||
calendarEl,
|
calendarEl,
|
||||||
dutyListEl,
|
dutyListEl,
|
||||||
loadingEl,
|
loadingEl,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
prevBtn,
|
prevBtn,
|
||||||
nextBtn
|
nextBtn
|
||||||
} from "./dom.js";
|
} from "./dom.js";
|
||||||
|
import { t } from "./i18n.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show access-denied view and hide calendar/list/loading/error.
|
* 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 (loadingEl) loadingEl.classList.add("hidden");
|
||||||
if (errorEl) errorEl.hidden = true;
|
if (errorEl) errorEl.hidden = true;
|
||||||
if (accessDeniedEl) {
|
if (accessDeniedEl) {
|
||||||
accessDeniedEl.innerHTML = "<p>Доступ запрещён.</p>";
|
const msg = t(state.lang, "access_denied");
|
||||||
|
accessDeniedEl.innerHTML = "<p>" + msg + "</p>";
|
||||||
if (serverDetail && serverDetail.trim()) {
|
if (serverDetail && serverDetail.trim()) {
|
||||||
const detail = document.createElement("p");
|
const detail = document.createElement("p");
|
||||||
detail.className = "access-denied-detail";
|
detail.className = "access-denied-detail";
|
||||||
|
|||||||
Reference in New Issue
Block a user