This commit is contained in:
325
panel/intl/i18n.tsx
Normal file
325
panel/intl/i18n.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createSignal,
|
||||
JSX,
|
||||
onCleanup,
|
||||
onMount,
|
||||
ParentComponent,
|
||||
useContext
|
||||
} from 'solid-js'
|
||||
import strings from './strings.json'
|
||||
|
||||
/**
|
||||
* Тип для поддерживаемых языков
|
||||
*/
|
||||
export type Language = 'ru' | 'en'
|
||||
|
||||
/**
|
||||
* Ключ для сохранения языка в localStorage
|
||||
*/
|
||||
const STORAGE_KEY = 'admin-language'
|
||||
|
||||
/**
|
||||
* Регекс для детекции кириллических символов
|
||||
*/
|
||||
const CYRILLIC_REGEX = /[\u0400-\u04FF]/
|
||||
|
||||
/**
|
||||
* Контекст интернационализации
|
||||
*/
|
||||
interface I18nContextType {
|
||||
language: () => Language
|
||||
setLanguage: (lang: Language) => void
|
||||
t: (key: string) => string
|
||||
tr: (text: string) => string
|
||||
isRussian: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаем контекст
|
||||
*/
|
||||
const I18nContext = createContext<I18nContextType>()
|
||||
|
||||
/**
|
||||
* Функция для перевода строки
|
||||
*/
|
||||
const translateString = (text: string, language: Language): string => {
|
||||
// Если язык русский или строка не содержит кириллицу, возвращаем как есть
|
||||
if (language === 'ru' || !CYRILLIC_REGEX.test(text)) {
|
||||
return text
|
||||
}
|
||||
|
||||
// Ищем перевод в словаре
|
||||
const translation = strings[text as keyof typeof strings]
|
||||
return translation || text
|
||||
}
|
||||
|
||||
/**
|
||||
* Автоматический переводчик элементов
|
||||
* Перехватывает создание JSX элементов и автоматически делает кириллические строки реактивными
|
||||
*/
|
||||
const AutoTranslator = (props: { children: JSX.Element; language: () => Language }) => {
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
let observer: MutationObserver | undefined
|
||||
|
||||
// Кэш для переведенных элементов
|
||||
const translationCache = new WeakMap<Node, string>()
|
||||
|
||||
// Функция для обновления текстового содержимого
|
||||
const updateTextContent = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const originalText = node.textContent || ''
|
||||
|
||||
// Проверяем, содержит ли кириллицу
|
||||
if (CYRILLIC_REGEX.test(originalText)) {
|
||||
const currentLang = props.language()
|
||||
const translatedText = translateString(originalText, currentLang)
|
||||
|
||||
// Обновляем только если текст изменился
|
||||
if (node.textContent !== translatedText) {
|
||||
console.log(`📝 Переводим текстовый узел "${originalText}" -> "${translatedText}"`)
|
||||
node.textContent = translatedText
|
||||
translationCache.set(node, originalText) // Сохраняем оригинал
|
||||
}
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element
|
||||
|
||||
// Переводим атрибуты
|
||||
const attributesToTranslate = ['title', 'placeholder', 'alt', 'aria-label', 'data-placeholder']
|
||||
attributesToTranslate.forEach((attr) => {
|
||||
const value = element.getAttribute(attr)
|
||||
if (value && CYRILLIC_REGEX.test(value)) {
|
||||
const currentLang = props.language()
|
||||
const translatedValue = translateString(value, currentLang)
|
||||
if (translatedValue !== value) {
|
||||
console.log(`📝 Переводим атрибут ${attr}="${value}" -> "${translatedValue}"`)
|
||||
element.setAttribute(attr, translatedValue)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Специальная обработка элементов с текстом (кнопки, ссылки, лейблы, заголовки и т.д.)
|
||||
const textElements = [
|
||||
'BUTTON',
|
||||
'A',
|
||||
'LABEL',
|
||||
'SPAN',
|
||||
'DIV',
|
||||
'P',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'TD',
|
||||
'TH'
|
||||
]
|
||||
if (textElements.includes(element.tagName)) {
|
||||
// Более приоритетная обработка для кнопок
|
||||
if (element.tagName === 'BUTTON') {
|
||||
console.log(`👆 Проверка кнопки: "${element.textContent?.trim()}"`)
|
||||
}
|
||||
|
||||
// Ищем прямые текстовые узлы внутри элемента
|
||||
const directTextNodes = Array.from(element.childNodes).filter(
|
||||
(child) => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()
|
||||
)
|
||||
|
||||
// Если есть прямые текстовые узлы, обрабатываем их
|
||||
directTextNodes.forEach((textNode) => {
|
||||
const text = textNode.textContent || ''
|
||||
if (CYRILLIC_REGEX.test(text)) {
|
||||
const currentLang = props.language()
|
||||
const translatedText = translateString(text, currentLang)
|
||||
if (translatedText !== text) {
|
||||
console.log(`📝 Переводим "${text}" -> "${translatedText}" (${element.tagName})`)
|
||||
textNode.textContent = translatedText
|
||||
translationCache.set(textNode, text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Дополнительная проверка для кнопок с вложенными элементами
|
||||
if (element.tagName === 'BUTTON' && directTextNodes.length === 0) {
|
||||
// Если у кнопки нет прямых текстовых узлов, но есть вложенные элементы
|
||||
const buttonText = element.textContent?.trim()
|
||||
if (buttonText && CYRILLIC_REGEX.test(buttonText)) {
|
||||
console.log(`🔍 Кнопка с вложенными элементами: "${buttonText}"`)
|
||||
|
||||
// Проверяем, есть ли у кнопки value атрибут
|
||||
const valueAttr = element.getAttribute('value')
|
||||
if (valueAttr && CYRILLIC_REGEX.test(valueAttr)) {
|
||||
const currentLang = props.language()
|
||||
const translatedValue = translateString(valueAttr, currentLang)
|
||||
if (translatedValue !== valueAttr) {
|
||||
console.log(`📝 Переводим value="${valueAttr}" -> "${translatedValue}"`)
|
||||
element.setAttribute('value', translatedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Рекурсивно обрабатываем дочерние узлы
|
||||
Array.from(node.childNodes).forEach(updateTextContent)
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для обновления всего контейнера
|
||||
const updateAll = () => {
|
||||
if (containerRef) {
|
||||
updateTextContent(containerRef)
|
||||
}
|
||||
}
|
||||
|
||||
// Настройка MutationObserver для отслеживания новых элементов
|
||||
const setupObserver = () => {
|
||||
if (!containerRef) return
|
||||
|
||||
observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach(updateTextContent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(containerRef, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
}
|
||||
|
||||
// Реагируем на изменения языка
|
||||
createEffect(() => {
|
||||
const currentLang = props.language()
|
||||
console.log('🌐 Язык изменился на:', currentLang)
|
||||
updateAll() // обновляем все тексты при изменении языка
|
||||
})
|
||||
|
||||
// Инициализация при монтировании
|
||||
onMount(() => {
|
||||
if (containerRef) {
|
||||
updateAll()
|
||||
setupObserver()
|
||||
}
|
||||
})
|
||||
|
||||
// Очистка
|
||||
onCleanup(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'contents' }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Провайдер интернационализации с автоматическим переводом
|
||||
*/
|
||||
export const I18nProvider: ParentComponent = (props) => {
|
||||
const [language, setLanguage] = createSignal<Language>('ru')
|
||||
|
||||
/**
|
||||
* Функция перевода по ключу
|
||||
*/
|
||||
const t = (key: string): string => {
|
||||
const currentLang = language()
|
||||
if (currentLang === 'ru') {
|
||||
return key
|
||||
}
|
||||
|
||||
const translation = strings[key as keyof typeof strings]
|
||||
return translation || key
|
||||
}
|
||||
|
||||
/**
|
||||
* Реактивная функция перевода - использует текущий язык
|
||||
*/
|
||||
const tr = (text: string): string => {
|
||||
const currentLang = language()
|
||||
if (currentLang === 'ru' || !CYRILLIC_REGEX.test(text)) {
|
||||
return text
|
||||
}
|
||||
const translation = strings[text as keyof typeof strings]
|
||||
return translation || text
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, русский ли язык
|
||||
*/
|
||||
const isRussian = () => language() === 'ru'
|
||||
|
||||
/**
|
||||
* Загружаем язык из localStorage при инициализации
|
||||
*/
|
||||
onMount(() => {
|
||||
const savedLanguage = localStorage.getItem(STORAGE_KEY) as Language
|
||||
if (savedLanguage && (savedLanguage === 'ru' || savedLanguage === 'en')) {
|
||||
setLanguage(savedLanguage)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Сохраняем язык в localStorage при изменении и перезагружаем страницу
|
||||
*/
|
||||
const handleLanguageChange = (lang: Language) => {
|
||||
// Сохраняем новый язык
|
||||
localStorage.setItem(STORAGE_KEY, lang)
|
||||
|
||||
// Если язык действительно изменился
|
||||
if (language() !== lang) {
|
||||
console.log(`🔄 Перезагрузка страницы после смены языка с ${language()} на ${lang}`)
|
||||
|
||||
// Устанавливаем сигнал (хотя это не обязательно при перезагрузке)
|
||||
setLanguage(lang)
|
||||
|
||||
// Перезагружаем страницу для корректного обновления всех DOM элементов
|
||||
window.location.reload()
|
||||
} else {
|
||||
// Если язык не изменился, просто обновляем сигнал
|
||||
setLanguage(lang)
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue: I18nContextType = {
|
||||
language,
|
||||
setLanguage: handleLanguageChange,
|
||||
t,
|
||||
tr,
|
||||
isRussian
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={contextValue}>
|
||||
<AutoTranslator language={language}>{props.children}</AutoTranslator>
|
||||
</I18nContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для использования контекста интернационализации
|
||||
*/
|
||||
export const useI18n = (): I18nContextType => {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error('useI18n must be used within I18nProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для получения функции перевода
|
||||
*/
|
||||
export const useTranslation = () => {
|
||||
const { t, tr, language, isRussian } = useI18n()
|
||||
return { t, tr, language: language(), isRussian: isRussian() }
|
||||
}
|
234
panel/intl/strings.json
Normal file
234
panel/intl/strings.json
Normal file
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"Панель администратора": "Admin Panel",
|
||||
"Выйти": "Logout",
|
||||
"Авторы": "Authors",
|
||||
"Публикации": "Publications",
|
||||
"Темы": "Topics",
|
||||
"Сообщества": "Communities",
|
||||
"Коллекции": "Collections",
|
||||
"Приглашения": "Invites",
|
||||
"Переменные среды": "Environment Variables",
|
||||
"Ошибка при выходе": "Logout error",
|
||||
|
||||
"Вход в панель администратора": "Admin Panel Login",
|
||||
"Имя пользователя": "Username",
|
||||
"Пароль": "Password",
|
||||
"Войти": "Login",
|
||||
"Вход...": "Logging in...",
|
||||
"Ошибка при входе": "Login error",
|
||||
"Неверные учетные данные": "Invalid credentials",
|
||||
|
||||
"ID": "ID",
|
||||
"Email": "Email",
|
||||
"Имя": "Name",
|
||||
"Создан": "Created",
|
||||
"Создано": "Created",
|
||||
"Роли": "Roles",
|
||||
"Загрузка данных...": "Loading data...",
|
||||
"Нет данных для отображения": "No data to display",
|
||||
"Данные пользователя успешно обновлены": "User data successfully updated",
|
||||
"Ошибка обновления данных пользователя": "Error updating user data",
|
||||
|
||||
"Заголовок": "Title",
|
||||
"Слаг": "Slug",
|
||||
"Статус": "Status",
|
||||
"Содержимое": "Content",
|
||||
"Опубликовано": "Published",
|
||||
"Действия": "Actions",
|
||||
"Загрузка публикаций...": "Loading publications...",
|
||||
"Нет публикаций для отображения": "No publications to display",
|
||||
"Содержимое публикации": "Publication content",
|
||||
"Введите содержимое публикации...": "Enter publication content...",
|
||||
"Содержимое публикации обновлено": "Publication content updated",
|
||||
"Удалена": "Deleted",
|
||||
"Опубликована": "Published",
|
||||
"Черновик": "Draft",
|
||||
|
||||
"Название": "Title",
|
||||
"Описание": "Description",
|
||||
"Создатель": "Creator",
|
||||
"Подписчики": "Subscribers",
|
||||
"Сообщество": "Community",
|
||||
"Все сообщества": "All communities",
|
||||
"Родители": "Parents",
|
||||
"Сортировка:": "Sorting:",
|
||||
"По названию": "By title",
|
||||
"Загрузка топиков...": "Loading topics...",
|
||||
"Все": "All",
|
||||
"Действие": "Action",
|
||||
"Удалить": "Delete",
|
||||
"Слить": "Merge",
|
||||
"Выбрать все": "Select all",
|
||||
"Подтверждение удаления": "Delete confirmation",
|
||||
"Топик успешно обновлен": "Topic successfully updated",
|
||||
"Ошибка обновления топика": "Error updating topic",
|
||||
"Топик успешно создан": "Topic successfully created",
|
||||
"Выберите действие и топики": "Select action and topics",
|
||||
"Топик успешно удален": "Topic successfully deleted",
|
||||
"Ошибка удаления топика": "Error deleting topic",
|
||||
"Выберите одну тему для назначения родителя": "Select one topic to assign parent",
|
||||
|
||||
"Загрузка сообществ...": "Loading communities...",
|
||||
"Сообщество успешно создано": "Community successfully created",
|
||||
"Сообщество успешно обновлено": "Community successfully updated",
|
||||
"Ошибка создания": "Creation error",
|
||||
"Ошибка обновления": "Update error",
|
||||
"Сообщество успешно удалено": "Community successfully deleted",
|
||||
"Удалить сообщество": "Delete community",
|
||||
|
||||
"Загрузка коллекций...": "Loading collections...",
|
||||
"Коллекция успешно создана": "Collection successfully created",
|
||||
"Коллекция успешно обновлена": "Collection successfully updated",
|
||||
"Коллекция успешно удалена": "Collection successfully deleted",
|
||||
"Удалить коллекцию": "Delete collection",
|
||||
|
||||
"Поиск по приглашающему, приглашаемому, публикации...": "Search by inviter, invitee, publication...",
|
||||
"Все статусы": "All statuses",
|
||||
"Ожидает ответа": "Pending",
|
||||
"Принято": "Accepted",
|
||||
"Отклонено": "Rejected",
|
||||
"Загрузка приглашений...": "Loading invites...",
|
||||
"Приглашения не найдены": "No invites found",
|
||||
"Удалить выбранные приглашения": "Delete selected invites",
|
||||
"Ожидает": "Pending",
|
||||
"Удалить приглашение": "Delete invite",
|
||||
"Приглашение успешно удалено": "Invite successfully deleted",
|
||||
"Не выбрано ни одного приглашения для удаления": "No invites selected for deletion",
|
||||
"Подтверждение пакетного удаления": "Bulk delete confirmation",
|
||||
"Без имени": "No name",
|
||||
|
||||
"Загрузка переменных окружения...": "Loading environment variables...",
|
||||
"Переменные окружения не найдены": "No environment variables found",
|
||||
"Как добавить переменные?": "How to add variables?",
|
||||
"Ключ": "Key",
|
||||
"Значение": "Value",
|
||||
"не задано": "not set",
|
||||
"Скопировать": "Copy",
|
||||
"Скрыть": "Hide",
|
||||
"Показать": "Show",
|
||||
"Не удалось обновить переменную": "Failed to update variable",
|
||||
"Ошибка при обновлении переменной": "Error updating variable",
|
||||
|
||||
"Загрузка...": "Loading...",
|
||||
"Загрузка тем...": "Loading topics...",
|
||||
"Обновить": "Refresh",
|
||||
"Отмена": "Cancel",
|
||||
"Сохранить": "Save",
|
||||
"Создать": "Create",
|
||||
"Создать сообщество": "Create community",
|
||||
"Редактировать": "Edit",
|
||||
"Поиск": "Search",
|
||||
"Поиск...": "Search...",
|
||||
|
||||
"Управление иерархией тем": "Topic Hierarchy Management",
|
||||
"Инструкции:": "Instructions:",
|
||||
"🔍 Найдите тему по названию или прокрутите список": "🔍 Find topic by title or scroll through list",
|
||||
"# Нажмите на тему, чтобы выбрать её для перемещения (синяя рамка)": "# Click on topic to select it for moving (blue border)",
|
||||
"📂 Нажмите на другую тему, чтобы сделать её родителем (зеленая рамка)": "📂 Click on another topic to make it parent (green border)",
|
||||
"🏠 Используйте кнопку \"Сделать корневой\" для перемещения на верхний уровень": "🏠 Use \"Make root\" button to move to top level",
|
||||
"▶/▼ Раскрывайте/сворачивайте ветки дерева": "▶/▼ Expand/collapse tree branches",
|
||||
"Поиск темы:": "Search topic:",
|
||||
"Введите название темы для поиска...": "Enter topic title to search...",
|
||||
"✅ Найдена тема:": "✅ Found topic:",
|
||||
"❌ Тема не найдена": "❌ Topic not found",
|
||||
"Планируемые изменения": "Planned changes",
|
||||
"станет корневой темой": "will become root topic",
|
||||
"переместится под тему": "will move under topic",
|
||||
"Выбрана для перемещения:": "Selected for moving:",
|
||||
"🏠 Сделать корневой темой": "🏠 Make root topic",
|
||||
"❌ Отменить выбор": "❌ Cancel selection",
|
||||
"Сохранить изменения": "Save changes",
|
||||
"Выбрана тема": "Selected topic",
|
||||
"для перемещения. Теперь нажмите на новую родительскую тему или используйте \"Сделать корневой\".": "for moving. Now click on new parent topic or use \"Make root\".",
|
||||
"Нельзя переместить тему в своего потомка": "Cannot move topic to its descendant",
|
||||
"Нет изменений для сохранения": "No changes to save",
|
||||
|
||||
"Назначить родительскую тему": "Assign parent topic",
|
||||
"Редактируемая тема:": "Editing topic:",
|
||||
"Текущее расположение:": "Current location:",
|
||||
"Поиск новой родительской темы:": "Search for new parent topic:",
|
||||
"Введите название темы...": "Enter topic title...",
|
||||
"Выберите новую родительскую тему:": "Select new parent topic:",
|
||||
"Путь:": "Path:",
|
||||
"Предварительный просмотр:": "Preview:",
|
||||
"Новое расположение:": "New location:",
|
||||
"Не найдено подходящих тем по запросу": "No matching topics found for query",
|
||||
"Нет доступных родительских тем": "No available parent topics",
|
||||
"Назначение...": "Assigning...",
|
||||
"Назначить родителя": "Assign parent",
|
||||
"Неизвестная тема": "Unknown topic",
|
||||
|
||||
"Создать тему": "Create topic",
|
||||
"Слияние тем": "Topic merge",
|
||||
"Выбор целевой темы": "Target topic selection",
|
||||
"Выберите целевую тему": "Select target topic",
|
||||
"Выбор исходных тем для слияния": "Source topics selection for merge",
|
||||
"Настройки слияния": "Merge settings",
|
||||
"Сохранить свойства целевой темы": "Keep target topic properties",
|
||||
"Предпросмотр слияния:": "Merge preview:",
|
||||
"Целевая тема:": "Target topic:",
|
||||
"Исходные темы:": "Source topics:",
|
||||
"шт.": "pcs.",
|
||||
"Действие:": "Action:",
|
||||
"Все подписчики, публикации и черновики будут перенесены в целевую": "All subscribers, publications and drafts will be moved to target",
|
||||
"Выполняется слияние...": "Merging...",
|
||||
"Слить темы": "Merge topics",
|
||||
"Невозможно выполнить слияние с текущими настройками": "Cannot perform merge with current settings",
|
||||
|
||||
"Автор:": "Author:",
|
||||
"Просмотры:": "Views:",
|
||||
"Содержание": "Content",
|
||||
|
||||
"PENDING": "PENDING",
|
||||
"ACCEPTED": "ACCEPTED",
|
||||
"REJECTED": "REJECTED",
|
||||
"Текущий статус приглашения": "Current invite status",
|
||||
"Информация о приглашении": "Invite information",
|
||||
"Приглашающий:": "Inviter:",
|
||||
"Приглашаемый:": "Invitee:",
|
||||
"Публикация:": "Publication:",
|
||||
"Приглашающий и приглашаемый не могут быть одним и тем же автором": "Inviter and invitee cannot be the same author",
|
||||
"Создание нового приглашения": "Creating new invite",
|
||||
|
||||
"уникальный-идентификатор": "unique-identifier",
|
||||
"Название коллекции": "Collection title",
|
||||
"Описание коллекции...": "Collection description...",
|
||||
"Название сообщества": "Community title",
|
||||
"Описание сообщества...": "Community description...",
|
||||
"Создать коллекцию": "Create collection",
|
||||
|
||||
"body": "Body",
|
||||
"Описание топика": "Topic body",
|
||||
"Введите содержимое топика...": "Enter topic content...",
|
||||
"Содержимое топика обновлено": "Topic content updated",
|
||||
|
||||
"Выберите действие:": "Select action:",
|
||||
"Установить нового родителя": "Set new parent",
|
||||
"Выбор родительской темы:": "Parent topic selection:",
|
||||
"Поиск родительской темы...": "Search parent topic...",
|
||||
|
||||
"Иван Иванов": "Ivan Ivanov",
|
||||
"Системная информация": "System information",
|
||||
"Дата регистрации:": "Registration date:",
|
||||
"Последняя активность:": "Last activity:",
|
||||
"Основные данные": "Basic data",
|
||||
|
||||
"Введите значение переменной...": "Enter variable value...",
|
||||
"Скрыть превью": "Hide preview",
|
||||
"Показать превью": "Show preview",
|
||||
|
||||
"Нажмите для редактирования...": "Click to edit...",
|
||||
|
||||
"Поиск по email, имени или ID...": "Search by email, name or ID...",
|
||||
"Поиск по заголовку, slug или ID...": "Search by title, slug or ID...",
|
||||
"Введите HTML описание топика...": "Enter HTML topic description...",
|
||||
"https://example.com/image.jpg": "https://example.com/image.jpg",
|
||||
"1, 5, 12": "1, 5, 12",
|
||||
"user@example.com": "user@example.com",
|
||||
"1": "1",
|
||||
"2": "2",
|
||||
"123": "123",
|
||||
"Введите содержимое media.body...": "Enter media.body content...",
|
||||
"Поиск по названию, slug или ID...": "Search by title, slug or ID...",
|
||||
"Дискурс": "Discours"
|
||||
}
|
Reference in New Issue
Block a user