diff --git a/webapp/js/api.js b/webapp/js/api.js index b97509a..58e0e90 100644 --- a/webapp/js/api.js +++ b/webapp/js/api.js @@ -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 { diff --git a/webapp/js/calendar.js b/webapp/js/calendar.js index 2ac5459..702444c 100644 --- a/webapp/js/calendar.js +++ b/webapp/js/calendar.js @@ -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() + '
'; if (showMarkers) { + const lang = state.lang; if (dutyList.length) { html += '"; + "' aria-label=\"" + escapeHtml(t(lang, "aria.duty")) + "\">Д"; } if (unavailableList.length) { html += ''; + '" aria-label="' + escapeHtml(t(lang, "aria.unavailable")) + '">Н'; } if (vacationList.length) { html += ''; + '" aria-label="' + escapeHtml(t(lang, "aria.vacation")) + '">О'; } if (hasEvent) { html += - ''; } @@ -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(); } diff --git a/webapp/js/constants.js b/webapp/js/constants.js index 80fe6e2..f6102b5 100644 --- a/webapp/js/constants.js +++ b/webapp/js/constants.js @@ -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: "Отпуск" -}; diff --git a/webapp/js/dom.js b/webapp/js/dom.js index d4369f5..fe38b72 100644 --- a/webapp/js/dom.js +++ b/webapp/js/dom.js @@ -39,5 +39,7 @@ export const state = { /** @type {object[]} */ lastDutiesForList: [], /** @type {ReturnType|null} */ - todayRefreshInterval: null + todayRefreshInterval: null, + /** @type {'ru'|'en'} */ + lang: "ru" }; diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js index 3777d70..a4dd573 100644 --- a/webapp/js/dutyList.js +++ b/webapp/js/dutyList.js @@ -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 ( '
d.event_type === "duty"); if (filtered.length === 0) { dutyListEl.classList.remove("duty-timeline"); - dutyListEl.innerHTML = '

В этом месяце дежурств нет.

'; + dutyListEl.innerHTML = '

' + t(state.lang, "duty.none_this_month") + "

"; 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 - ? 'Сегодня' + + ? '' + + escapeHtml(t(state.lang, "duty.today")) + + '' + escapeHtml(dateLabel) + '' : '' + escapeHtml(dateLabel) + ""; diff --git a/webapp/js/hints.js b/webapp/js/hints.js index e54446e..4e45f11 100644 --- a/webapp/js/hints.js +++ b/webapp/js/hints.js @@ -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 ? '-' : ''; @@ -210,7 +222,7 @@ export function getDutyMarkerHintHtml(marker) { } if (rowHtmls.length === 0) return null; return ( - '
Дежурство:
' + + '
' + escapeHtml(t(lang, "hint.duty_title")) + '
' + rowHtmls .map((r) => '
' + r + "
") .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(); diff --git a/webapp/js/i18n.js b/webapp/js/i18n.js new file mode 100644 index 0000000..d6f00cf --- /dev/null +++ b/webapp/js/i18n.js @@ -0,0 +1,180 @@ +/** + * Internationalization: language detection and translations for webapp. + */ + +import { getInitData } from "./auth.js"; + +/** @type {Record>} */ +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} [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)); +} diff --git a/webapp/js/main.js b/webapp/js/main.js index d0c6279..d73a3ec 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -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; } diff --git a/webapp/js/ui.js b/webapp/js/ui.js index 0129839..e735dcf 100644 --- a/webapp/js/ui.js +++ b/webapp/js/ui.js @@ -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 = "

Доступ запрещён.

"; + const msg = t(state.lang, "access_denied"); + accessDeniedEl.innerHTML = "

" + msg + "

"; if (serverDetail && serverDetail.trim()) { const detail = document.createElement("p"); detail.className = "access-denied-detail";