site: add RU/EN language toggle with full i18n on landing
This commit is contained in:
+262
-27
@@ -3,6 +3,220 @@
|
||||
|
||||
const API = ''; // same origin (root -> launcher server)
|
||||
|
||||
const I18N = {
|
||||
ru: {
|
||||
'nav.server': 'Сервер',
|
||||
'nav.highlights': 'Хайлайты',
|
||||
'nav.season': 'Сезон',
|
||||
'nav.features': 'Возможности',
|
||||
'nav.download': 'Скачать',
|
||||
'nav.news': 'Новости',
|
||||
'nav.play': 'Играть сейчас',
|
||||
'hero.kicker': 'КАСТОМНЫЙ MINECRAFT ЛАУНЧЕР',
|
||||
'hero.title': 'Очнись от<br><span class="hero-title-accent">холода.</span>',
|
||||
'hero.sub': 'Zern оживляет твои любимые модпаки. Один лаунчер, любой загрузчик, без лишних хлопот — создан для сервера ZernMC и не только.',
|
||||
'hero.download': 'Скачать лаунчер',
|
||||
'hero.explore': 'Обзор',
|
||||
'hero.scroll': 'Листай вниз',
|
||||
'server.title': 'Сервер <span class="accent">жив.</span>',
|
||||
'server.sub': 'ZernMC — это кастомный Minecraft-сервер со своим лором, самописными плагинами и живым комьюнити. Вот что происходит прямо сейчас.',
|
||||
'server.status': 'Статус',
|
||||
'server.checking': 'Проверяем…',
|
||||
'server.players': 'Игроков онлайн',
|
||||
'server.mode': 'Режим',
|
||||
'server.season': 'Сезон Prologue',
|
||||
'server.online': 'Онлайн',
|
||||
'server.offline': 'Оффлайн',
|
||||
'server.unavailable': 'Недоступно',
|
||||
'highlights.title': 'Хайлайты <span class="accent">сервера.</span>',
|
||||
'highlights.sub': 'Моменты, запечатлённые на сервере ZernMC — войны, постройки и хаос.',
|
||||
'carousel.prev': 'Назад',
|
||||
'carousel.next': 'Вперёд',
|
||||
'carousel.cap1': 'Zern',
|
||||
'carousel.cap3': 'Это честно не я',
|
||||
'carousel.cap5': 'Спавн ТЦ (старое)',
|
||||
'carousel.cap6': 'Спавн склад (старое)',
|
||||
'carousel.alt': 'Хайлайт Zern',
|
||||
'features.title': 'Всё, что <span class="accent">тебе нужно.</span>',
|
||||
'features.sub': 'Лаунчер, который делает всю тяжёлую работу, чтобы ты просто играл.',
|
||||
'features.forge.title': 'Forge, NeoForge, Fabric',
|
||||
'features.forge.sub': 'Все основные модлоадеры подхватываются автоматически. Библиотеки предзагружаются и проверяются, так что установка работает — даже на медленном интернете.',
|
||||
'features.ui.title': 'Тёмный, быстрый нативный UI',
|
||||
'features.ui.sub': 'Лёгкий интерфейс на JavaFX с чистым тёмным оформлением. Быстро запускается, мгновенно реагирует и не мешает.',
|
||||
'features.proxy.title': 'Умные зеркала и прокси',
|
||||
'features.proxy.sub': 'Загрузки идут через быстрые зеркала с автоматическим фолбэком. Заблокировали или зарезали скорость? Прокси подхватит — ты больше не зависнешь на 80%.',
|
||||
'features.news.title': 'Новости и статус сервера',
|
||||
'features.news.sub': 'Чейнджлоги, ивенты и обновления приходят прямо в лаунчер. Ты всегда знаешь, что нового, ещё до нажатия «Играть».',
|
||||
'features.pack.title': 'Установка сборки в один клик',
|
||||
'features.pack.sub': 'Выбери сборку и жми «Установить». Манифесты версий, ванильные ассеты и библиотеки подтягиваются и проверяются автоматически. Просто.',
|
||||
'theme.title': 'Что внутри <span class="accent">сезона.</span>',
|
||||
'theme.sub': 'Prologue — модовый сезон на базе Create: Aeronautics, война и выживание. ОБТ — полигон, где всё это обкатывается.',
|
||||
'theme.zarya.tag': 'ОБТ · 1.20.1 Forge',
|
||||
'theme.zarya.title': 'Война в Заре',
|
||||
'theme.zarya.sub': 'Двухдневный ивент в городе Заря: группировка «Феникс» атакует, «Ласточка» обороняется, а жители выбирают сторону — или поднимают бунт. Военные правила.',
|
||||
'theme.obt.tag': 'ОБТ · тестовая стадия',
|
||||
'theme.obt.title': 'Открытый бета-сервер',
|
||||
'theme.obt.sub': 'Бета — это ивентовый сервер: городские бои, сценарий атомного реактора и правила, которые лягут в основу настоящего сезона.',
|
||||
'theme.air.tag': 'Сезон · 1.21.1 NeoForge',
|
||||
'theme.air.title': 'Корабли в небе',
|
||||
'theme.air.sub': 'Create: Aeronautics даёт дирижабли, субмарины и летающие механизмы. Поезда, дизели, большие пушки и атомный реактор — осваивай.',
|
||||
'theme.year.tag': 'Сезон · Prologue',
|
||||
'theme.year.title': 'Марафон на год',
|
||||
'theme.year.sub': 'Сезон — это марафон, а не спринт. Начни эту главу с первого дня и строй её вместе с комьюнити.',
|
||||
'theme.lore.tag': 'Сервер',
|
||||
'theme.lore.title': 'Свой лор и плагины',
|
||||
'theme.lore.sub': 'Самописные плагины, ивенты под историю сервера и системы, которые из коробки работают с лаунчером.',
|
||||
'theme.community.tag': 'Комьюнити',
|
||||
'theme.community.title': 'Голос и друзья',
|
||||
'theme.community.sub': 'Simple Voice Chat в игре, система друзей в лаунчере и Telegram-комьюнити, которое реально рулит — решения принимаются голосованием игроков.',
|
||||
'download.title': 'Скачать <span class="accent">Zern.</span>',
|
||||
'download.sub': 'Скачай лаунчер для Windows и заходи на сервер ZernMC.',
|
||||
'download.latest': 'Скачать актуальную версию',
|
||||
'download.mirrors': 'Зеркала',
|
||||
'download.note': 'Без платных подписок и рекламы. Просто лаунчер. Системные требования: Windows 10+, 4 ГБ ОЗУ.',
|
||||
'news.title': 'Что <span class="accent">нового.</span>',
|
||||
'news.sub': 'Последние чейнджлоги и обновления команды Zern.',
|
||||
'news.empty': 'Новостей пока нет.',
|
||||
'news.error': 'Новости временно недоступны.',
|
||||
'news.update': 'Обновление',
|
||||
'footer.note': 'Не аффилирован с Mojang или Microsoft. Minecraft — торговая марка Mojang AB.'
|
||||
},
|
||||
en: {
|
||||
'nav.server': 'Server',
|
||||
'nav.highlights': 'Highlights',
|
||||
'nav.season': 'Season',
|
||||
'nav.features': 'Features',
|
||||
'nav.download': 'Download',
|
||||
'nav.news': 'News',
|
||||
'nav.play': 'Play now',
|
||||
'hero.kicker': 'CUSTOM MINECRAFT LAUNCHER',
|
||||
'hero.title': 'Awaken from<br><span class="hero-title-accent">the cold.</span>',
|
||||
'hero.sub': 'Zern brings your favourite modpacks to life. One launcher, every loader, no hassle — built for the ZernMC server and beyond.',
|
||||
'hero.download': 'Download launcher',
|
||||
'hero.explore': 'Explore',
|
||||
'hero.scroll': 'Scroll to explore',
|
||||
'server.title': 'The server is <span class="accent">alive.</span>',
|
||||
'server.sub': 'ZernMC is a custom Minecraft server with its own lore, hand-written plugins and a living community. This is what\'s happening right now.',
|
||||
'server.status': 'Status',
|
||||
'server.checking': 'Checking…',
|
||||
'server.players': 'Players online',
|
||||
'server.mode': 'Mode',
|
||||
'server.season': 'Season Prologue',
|
||||
'server.online': 'Online',
|
||||
'server.offline': 'Offline',
|
||||
'server.unavailable': 'Unavailable',
|
||||
'highlights.title': 'Server <span class="accent">highlights.</span>',
|
||||
'highlights.sub': 'Moments captured on the ZernMC server — wars, builds and chaos.',
|
||||
'carousel.prev': 'Previous',
|
||||
'carousel.next': 'Next',
|
||||
'carousel.cap1': 'Zern',
|
||||
'carousel.cap3': 'This is honestly not me',
|
||||
'carousel.cap5': 'Spawn mall (old)',
|
||||
'carousel.cap6': 'Spawn storage (old)',
|
||||
'carousel.alt': 'Zern highlight',
|
||||
'features.title': 'Everything <span class="accent">you need.</span>',
|
||||
'features.sub': 'A launcher that does the heavy lifting, so you can just play.',
|
||||
'features.forge.title': 'Forge, NeoForge, Fabric',
|
||||
'features.forge.sub': 'Every major mod loader handled automatically. Libraries are prefetched and verified, so installs just work — even on slow networks.',
|
||||
'features.ui.title': 'Dark, fast, native UI',
|
||||
'features.ui.sub': 'A lightweight JavaFX interface with a clean dark theme. Starts quick, responds instantly, and stays out of your way.',
|
||||
'features.proxy.title': 'Smart mirrors & proxy',
|
||||
'features.proxy.sub': 'Downloads route through fast mirrors with automatic failover. Blocked or throttled? The proxy takes over so you never stall at 80%.',
|
||||
'features.news.title': 'News & server status',
|
||||
'features.news.sub': 'Changelogs, events and updates stream straight into the launcher. You always know what\'s new before you hit play.',
|
||||
'features.pack.title': 'One-click pack install',
|
||||
'features.pack.sub': 'Pick a pack, hit install. Version manifests, vanilla assets and libraries are pulled and verified automatically. Simple.',
|
||||
'theme.title': 'What\'s inside <span class="accent">the season.</span>',
|
||||
'theme.sub': 'Prologue — a modded season built around Create: Aeronautics, war and survival. OBT is the testing ground for the whole idea.',
|
||||
'theme.zarya.tag': 'OBT · 1.20.1 Forge',
|
||||
'theme.zarya.title': 'War in Zarya',
|
||||
'theme.zarya.sub': 'Two-day event set in the city of Zarya: the Fenix group attacks, Lastochka defends, and civilians pick a side — or rise up. Military rules apply.',
|
||||
'theme.obt.tag': 'OBT · test stage',
|
||||
'theme.obt.title': 'Open beta server',
|
||||
'theme.obt.sub': 'The beta is an event server: city battles, an atomic-reactor city-life scenario and rule sets that will shape the real season.',
|
||||
'theme.air.tag': 'Season · 1.21.1 NeoForge',
|
||||
'theme.air.title': 'Ships in the sky',
|
||||
'theme.air.sub': 'Create: Aeronautics gives you airships, submarines and flying contraptions. Trains, diesel engines, big cannons and a nuclear reactor to master.',
|
||||
'theme.year.tag': 'Season · Prologue',
|
||||
'theme.year.title': 'A year-long run',
|
||||
'theme.year.sub': 'Each season is a marathon, not a sprint. Start this chapter from day one and shape it with the community.',
|
||||
'theme.lore.tag': 'Server',
|
||||
'theme.lore.title': 'Own lore & plugins',
|
||||
'theme.lore.sub': 'Hand-written plugins, events written for the server story, and systems the launcher integrates with out of the box.',
|
||||
'theme.community.tag': 'Community',
|
||||
'theme.community.title': 'Voice & friends',
|
||||
'theme.community.sub': 'Simple Voice Chat in-game, a friends system in the launcher and a Telegram community that runs the show — decisions are voted on by players.',
|
||||
'download.title': 'Get <span class="accent">Zern.</span>',
|
||||
'download.sub': 'Download the launcher for Windows and join the ZernMC server.',
|
||||
'download.latest': 'Download latest',
|
||||
'download.mirrors': 'Mirrors',
|
||||
'download.note': 'No paywalls, no ads. Just a launcher. System requirements: Windows 10+, 4 GB RAM.',
|
||||
'news.title': 'What\'s <span class="accent">new.</span>',
|
||||
'news.sub': 'Latest changelogs and updates from the Zern team.',
|
||||
'news.empty': 'No news yet.',
|
||||
'news.error': 'News is temporarily unavailable.',
|
||||
'news.update': 'Update',
|
||||
'footer.note': 'Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.'
|
||||
}
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'zern_lang';
|
||||
|
||||
function getLang() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === 'ru' || saved === 'en') return saved;
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'ru'; // default: Russian
|
||||
}
|
||||
|
||||
let currentLang = getLang();
|
||||
|
||||
function tr(key) {
|
||||
const dict = I18N[currentLang] || I18N.ru;
|
||||
return dict[key] != null ? dict[key] : key;
|
||||
}
|
||||
|
||||
function applyI18n() {
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
const toggle = document.getElementById('langToggleLabel');
|
||||
if (toggle) toggle.textContent = currentLang === 'ru' ? 'EN' : 'RU';
|
||||
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
el.textContent = tr(el.dataset.i18n);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-html]').forEach((el) => {
|
||||
el.innerHTML = tr(el.dataset.i18nHtml);
|
||||
});
|
||||
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
|
||||
el.setAttribute('aria-label', tr(el.dataset.i18nAria));
|
||||
});
|
||||
|
||||
// Carousel captions & alts are created dynamically with tr() at init
|
||||
document.querySelectorAll('.carousel-caption[data-caption-key]').forEach((el) => {
|
||||
el.textContent = tr(el.dataset.captionKey);
|
||||
});
|
||||
document.querySelectorAll('.carousel-slide img').forEach((img) => {
|
||||
const slide = img.closest('.carousel-slide');
|
||||
const cap = slide && slide.querySelector('.carousel-caption[data-caption-key]');
|
||||
if (cap) img.alt = tr(cap.dataset.captionKey);
|
||||
else img.alt = tr('carousel.alt');
|
||||
});
|
||||
|
||||
// Live-update dynamic strings
|
||||
const statusValue = document.getElementById('srv-status');
|
||||
if (statusValue && statusValue.dataset.state) {
|
||||
updateStatusUI(statusValue.dataset.state);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLang() {
|
||||
currentLang = currentLang === 'ru' ? 'en' : 'ru';
|
||||
try { localStorage.setItem(STORAGE_KEY, currentLang); } catch (e) { /* ignore */ }
|
||||
applyI18n();
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -67,7 +281,6 @@
|
||||
const statusEl = document.getElementById('srv-status');
|
||||
const playersEl = document.getElementById('srv-players');
|
||||
const versionEl = document.getElementById('srv-version');
|
||||
const dot = document.querySelector('#srv-status .status-dot');
|
||||
if (!statusEl) return;
|
||||
|
||||
try {
|
||||
@@ -75,37 +288,53 @@
|
||||
const data = await res.json();
|
||||
|
||||
if (data.online) {
|
||||
if (dot) dot.className = 'status-dot status-dot--online';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--online"></span><span class="status-text--online">Online</span>';
|
||||
statusEl.dataset.state = 'online';
|
||||
if (playersEl) playersEl.textContent = `${data.players_online} / ${data.players_max}`;
|
||||
if (versionEl && data.version) versionEl.textContent = data.version;
|
||||
} else {
|
||||
if (dot) dot.className = 'status-dot status-dot--offline';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span class="status-text--offline">Offline</span>';
|
||||
statusEl.dataset.state = 'offline';
|
||||
if (playersEl) playersEl.textContent = '— / —';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.dataset.state = 'unavailable';
|
||||
if (playersEl) playersEl.textContent = '— / —';
|
||||
}
|
||||
updateStatusUI(statusEl.dataset.state);
|
||||
}
|
||||
|
||||
function updateStatusUI(state) {
|
||||
const statusEl = document.getElementById('srv-status');
|
||||
const dot = document.querySelector('#srv-status .status-dot');
|
||||
if (!statusEl) return;
|
||||
|
||||
if (state === 'online') {
|
||||
if (dot) dot.className = 'status-dot status-dot--online';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--online"></span><span class="status-text--online">' + tr('server.online') + '</span>';
|
||||
} else if (state === 'offline') {
|
||||
if (dot) dot.className = 'status-dot status-dot--offline';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span>Unavailable</span>';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span class="status-text--offline">' + tr('server.offline') + '</span>';
|
||||
} else {
|
||||
if (dot) dot.className = 'status-dot status-dot--offline';
|
||||
statusEl.innerHTML = '<span class="status-dot status-dot--offline"></span><span>' + tr('server.unavailable') + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
const HIGHLIGHTS = [
|
||||
{ file: '1.jpg', type: 'image', caption: 'Zern' },
|
||||
{ file: '2.mp4', type: 'video', caption: null },
|
||||
{ file: '3.jpg', type: 'image', caption: 'Это честно не я' },
|
||||
{ file: '4.mp4', type: 'video', caption: null },
|
||||
{ file: '5.jpg', type: 'image', caption: 'Спавн ТЦ (старое)' },
|
||||
{ file: '6.jpg', type: 'image', caption: 'Спавн склад (старое)' },
|
||||
{ file: '7.jpg', type: 'image', caption: null },
|
||||
{ file: '8.jpg', type: 'image', caption: null },
|
||||
{ file: '9.jpg', type: 'image', caption: null },
|
||||
{ file: '10.jpg', type: 'image', caption: null },
|
||||
{ file: '11.jpg', type: 'image', caption: null },
|
||||
{ file: '12.jpg', type: 'image', caption: null },
|
||||
{ file: '13.jpg', type: 'image', caption: null },
|
||||
{ file: '14.jpg', type: 'image', caption: null },
|
||||
{ file: '15.jpg', type: 'image', caption: null },
|
||||
{ file: '1.jpg', type: 'image', captionKey: 'carousel.cap1' },
|
||||
{ file: '2.mp4', type: 'video', captionKey: null },
|
||||
{ file: '3.jpg', type: 'image', captionKey: 'carousel.cap3' },
|
||||
{ file: '4.mp4', type: 'video', captionKey: null },
|
||||
{ file: '5.jpg', type: 'image', captionKey: 'carousel.cap5' },
|
||||
{ file: '6.jpg', type: 'image', captionKey: 'carousel.cap6' },
|
||||
{ file: '7.jpg', type: 'image', captionKey: null },
|
||||
{ file: '8.jpg', type: 'image', captionKey: null },
|
||||
{ file: '9.jpg', type: 'image', captionKey: null },
|
||||
{ file: '10.jpg', type: 'image', captionKey: null },
|
||||
{ file: '11.jpg', type: 'image', captionKey: null },
|
||||
{ file: '12.jpg', type: 'image', captionKey: null },
|
||||
{ file: '13.jpg', type: 'image', captionKey: null },
|
||||
{ file: '14.jpg', type: 'image', captionKey: null },
|
||||
{ file: '15.jpg', type: 'image', captionKey: null },
|
||||
];
|
||||
|
||||
function initCarousel() {
|
||||
@@ -131,15 +360,16 @@
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = '/highlights/' + h.file;
|
||||
img.alt = h.caption || 'Zern highlight';
|
||||
img.alt = h.captionKey ? tr(h.captionKey) : tr('carousel.alt');
|
||||
img.loading = 'lazy';
|
||||
slide.appendChild(img);
|
||||
}
|
||||
|
||||
if (h.caption) {
|
||||
if (h.captionKey) {
|
||||
const cap = document.createElement('span');
|
||||
cap.className = 'carousel-caption';
|
||||
cap.textContent = h.caption;
|
||||
cap.dataset.captionKey = h.captionKey;
|
||||
cap.textContent = tr(h.captionKey);
|
||||
slide.appendChild(cap);
|
||||
}
|
||||
|
||||
@@ -202,7 +432,7 @@
|
||||
if (!res.ok) throw new Error('news fetch failed');
|
||||
const data = await res.json();
|
||||
const news = (data.news || []).slice(0, 6);
|
||||
if (!news.length) { grid.innerHTML = '<p class="section-sub">No news yet.</p>'; return; }
|
||||
if (!news.length) { grid.innerHTML = '<p class="section-sub">' + tr('news.empty') + '</p>'; return; }
|
||||
|
||||
grid.innerHTML = '';
|
||||
news.forEach((n) => {
|
||||
@@ -225,7 +455,7 @@
|
||||
}
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = n.title || 'Update';
|
||||
title.textContent = n.title || tr('news.update');
|
||||
|
||||
const body = document.createElement('p');
|
||||
const text = stripMarkup(n.body);
|
||||
@@ -238,7 +468,7 @@
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[site] failed to load news', e);
|
||||
grid.innerHTML = '<p class="section-sub">News is temporarily unavailable.</p>';
|
||||
grid.innerHTML = '<p class="section-sub">' + tr('news.error') + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +520,11 @@
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
applyI18n();
|
||||
|
||||
const toggle = document.getElementById('langToggle');
|
||||
if (toggle) toggle.addEventListener('click', toggleLang);
|
||||
|
||||
loadLauncherInfo();
|
||||
loadMirrors();
|
||||
loadNews();
|
||||
|
||||
Reference in New Issue
Block a user