Files
telegram-shop/src/admin/routes/locales.js
NW a8bf50df24 feat: add i18n localization system (en/es/de) with admin panel
- Add i18n module with tForUser/tForLang/t functions and {{param}} interpolation
- Add 3 locale files: en.json, es.json, de.json (201 keys each)
- Add language selection on /start and /language command with flag emojis
- Localize all bot user-facing strings (handlers, keyboards, errors)
- Localize messageRouter keyboard matching via locale keys
- Add DB migrations 008 (language column) and 009 (language_set column)
- Add localization admin tab at /locales for editing translations
- Add userService.getUserLanguage/setUserLanguage methods
- Cache user object on msg.__user to avoid triple DB fetch
- Idempotent migrations with checkColumnExists guards
- Error boundary on i18n locale file loading
- Admin locales route uses AVAILABLE_LANGUAGES import
2026-06-25 21:22:32 +01:00

172 lines
5.8 KiB
JavaScript

import express, { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from '../../utils/logger.js';
import { layout } from '../views/layout.js';
import { AVAILABLE_LANGUAGES } from '../../i18n/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.join(__dirname, '..', '..', 'i18n', 'locales');
const router = Router();
router.get('/', (req, res) => {
try {
const files = fs.readdirSync(LOCALES_DIR).filter(f => f.endsWith('.json'));
const locales = {};
for (const file of files) {
const lang = file.replace('.json', '');
const content = fs.readFileSync(path.join(LOCALES_DIR, file), 'utf-8');
locales[lang] = JSON.parse(content);
}
const html = renderLocalesPage(locales);
res.send(html);
} catch (error) {
logger.error({ err: error }, 'Error loading locales');
res.status(500).send('Error loading locales');
}
});
router.post('/save', express.json(), (req, res) => {
try {
const { lang, key, value } = req.body;
if (!lang || !key || value === undefined) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!AVAILABLE_LANGUAGES.includes(lang)) {
return res.status(400).json({ error: 'Invalid language' });
}
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
const content = fs.readFileSync(filePath, 'utf-8');
const locale = JSON.parse(content);
const keys = key.split('.');
let obj = locale;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
fs.writeFileSync(filePath, JSON.stringify(locale, null, 2) + '\n', 'utf-8');
res.json({ success: true });
} catch (error) {
logger.error({ err: error }, 'Error saving locale');
res.status(500).json({ error: 'Error saving locale' });
}
});
function renderLocalesPage(locales) {
const sections = Object.keys(locales.en || {});
let sectionsHtml = '';
for (const section of sections) {
const keys = Object.keys(locales.en[section] || {});
let rowsHtml = '';
for (const key of keys) {
const fullKey = `${section}.${key}`;
const enVal = locales.en?.[section]?.[key] || '';
const deVal = locales.de?.[section]?.[key] || '';
const esVal = locales.es?.[section]?.[key] || '';
rowsHtml += `
<tr data-key="${fullKey}">
<td class="key-cell"><code>${fullKey}</code></td>
<td><input type="text" data-lang="en" data-key="${fullKey}" value="${escapeAttr(enVal)}" class="locale-input"></td>
<td><input type="text" data-lang="de" data-key="${fullKey}" value="${escapeAttr(deVal)}" class="locale-input"></td>
<td><input type="text" data-lang="es" data-key="${fullKey}" value="${escapeAttr(esVal)}" class="locale-input"></td>
</tr>`;
}
sectionsHtml += `
<div class="locale-section">
<h3>${section}</h3>
<table class="locale-table">
<thead>
<tr>
<th>Ключ</th>
<th>English</th>
<th>Deutsch</th>
<th>Español</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
}
const content = `
<div class="locale-actions">
<button id="saveAllBtn" class="btn btn-primary">💾 Сохранить все изменения</button>
<span id="saveStatus" class="save-status"></span>
</div>
${sectionsHtml}
<script>
document.getElementById('saveAllBtn').addEventListener('click', async () => {
const btn = document.getElementById('saveAllBtn');
const status = document.getElementById('saveStatus');
btn.disabled = true;
status.textContent = 'Сохранение...';
const inputs = document.querySelectorAll('.locale-input');
const changes = [];
for (const input of inputs) {
if (input.dataset.originalValue !== input.value) {
changes.push({
lang: input.dataset.lang,
key: input.dataset.key,
value: input.value
});
}
}
let saved = 0;
let errors = 0;
for (const change of changes) {
try {
const res = await fetch('/locales/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
});
if (res.ok) saved++;
else errors++;
} catch (e) {
errors++;
}
}
status.textContent = errors > 0
? 'Сохранено ' + saved + ' из ' + changes.length + '. Ошибок: ' + errors
: 'Сохранено ' + saved + ' из ' + changes.length + ' изменений ✓';
btn.disabled = false;
for (const input of inputs) {
input.dataset.originalValue = input.value;
}
});
document.querySelectorAll('.locale-input').forEach(input => {
input.dataset.originalValue = input.value;
});
</script>
`;
return layout('Локализация', content, 'locales');
}
function escapeAttr(str) {
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export default router;