1052 lines
50 KiB
JavaScript
1052 lines
50 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
const API = ''; // same origin (root -> launcher server)
|
||
|
||
const I18N = {
|
||
ru: {
|
||
'nav.server': 'Сервер',
|
||
'nav.highlights': 'Хайлайты',
|
||
'nav.season': 'Сезон',
|
||
'nav.features': 'Возможности',
|
||
'nav.showcase': 'Лаунчер',
|
||
'nav.download': 'Скачать',
|
||
'nav.news': 'Новости',
|
||
'nav.play': 'Играть сейчас',
|
||
'section.of': 'Раздел {n} из {m}',
|
||
'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 ГБ ОЗУ.',
|
||
'showcase.title': 'Показываем <span class="accent">лаунчер</span>',
|
||
'showcase.sub': 'Никаких статичных скриншотов — живой тур по интерфейсу. Нужно просто наблюдать: лаунчер сам открывает свои экраны.',
|
||
'preview.aria': 'ZernMC Launcher preview, auto-rotating screens',
|
||
'preview.login': 'Вход',
|
||
'preview.packs': 'Сборки',
|
||
'preview.news': 'Новости',
|
||
'preview.settings': 'Настройки',
|
||
'preview.install': 'Установка',
|
||
'preview.signIn': 'Вход',
|
||
'preview.username': 'Логин',
|
||
'preview.password': 'Пароль',
|
||
'preview.nav.packs': 'Сборки',
|
||
'preview.nav.news': 'Новости',
|
||
'preview.nav.friends': 'Друзья',
|
||
'preview.nav.settings': 'Настройки',
|
||
'preview.serverPacks': 'Серверные сборки',
|
||
'preview.localPacks': 'Локальные сборки',
|
||
'preview.stat.loader': 'Загрузчик',
|
||
'preview.stat.files': 'Файлы',
|
||
'preview.stat.size': 'Размер',
|
||
'preview.ready': 'Готов к запуску',
|
||
'preview.play': 'Играть',
|
||
'preview.news.update': 'Обновление',
|
||
'preview.news.event': 'Событие',
|
||
'preview.news1.title': 'Сезон Prologue запущен',
|
||
'preview.news1.body': 'Небо принадлежит дирижаблям. Добро пожаловать в Prologue — 1.21.1 NeoForge, Create: Aeronautics.',
|
||
'preview.news2.title': 'Война в Заре',
|
||
'preview.news2.body': '«Феникс» атакует, «Ласточка» защищает. Жители выбирают сторону — или бунт.',
|
||
'preview.settings.ram': 'Выделено ОЗУ',
|
||
'preview.settings.resolution': 'Разрешение',
|
||
'preview.settings.language': 'Язык',
|
||
'preview.settings.server': 'Сервер',
|
||
'preview.online': 'Онлайн',
|
||
'preview.install.title': 'Установка сборки…',
|
||
'preview.install.stage': 'Загрузка модов 130/130 · 1.8 ГБ',
|
||
'nav.login': 'Войти',
|
||
'nav.cabinet': 'Кабинет',
|
||
'nav.logout': 'Выйти',
|
||
'auth.title': 'Вход',
|
||
'auth.login': 'Вход',
|
||
'auth.register': 'Регистрация',
|
||
'auth.username': 'Логин',
|
||
'auth.password': 'Пароль',
|
||
'auth.confirm': 'Повтор пароля',
|
||
'auth.error': 'Ошибка',
|
||
'cabinet.title': 'Личный <span class="accent">кабинет.</span>',
|
||
'cabinet.sub': 'Ваш профиль и доступные сборки',
|
||
'cabinet.profile': 'Профиль',
|
||
'cabinet.packs': 'Сборки',
|
||
'cabinet.pass': 'Проходка',
|
||
'cabinet.available': 'Доступные сборки',
|
||
'cabinet.downloading': 'Скачивание...',
|
||
'cabinet.needPass': 'Нужна проходка',
|
||
'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.showcase': 'Launcher',
|
||
'nav.download': 'Download',
|
||
'nav.news': 'News',
|
||
'nav.play': 'Play now',
|
||
'section.of': 'Section {n} of {m}',
|
||
'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.',
|
||
'showcase.title': 'Meet the <span class="accent">launcher</span>',
|
||
'showcase.sub': 'No static screenshots — a live tour of the UI. Just watch: the launcher shows its screens on its own.',
|
||
'preview.aria': 'ZernMC Launcher preview, auto-rotating screens',
|
||
'preview.login': 'Sign in',
|
||
'preview.packs': 'Packs',
|
||
'preview.news': 'News',
|
||
'preview.settings': 'Settings',
|
||
'preview.install': 'Installing',
|
||
'preview.signIn': 'Sign In',
|
||
'preview.username': 'Username',
|
||
'preview.password': 'Password',
|
||
'preview.nav.packs': 'Packs',
|
||
'preview.nav.news': 'News',
|
||
'preview.nav.friends': 'Friends',
|
||
'preview.nav.settings': 'Settings',
|
||
'preview.serverPacks': 'Server Packs',
|
||
'preview.localPacks': 'Local Packs',
|
||
'preview.stat.loader': 'Loader',
|
||
'preview.stat.files': 'Files',
|
||
'preview.stat.size': 'Size',
|
||
'preview.ready': 'Ready to launch',
|
||
'preview.play': 'Play',
|
||
'preview.news.update': 'Update',
|
||
'preview.news.event': 'Event',
|
||
'preview.news1.title': 'Season Prologue has launched',
|
||
'preview.news1.body': 'The sky belongs to airships. Welcome to Prologue — 1.21.1 NeoForge, Create: Aeronautics.',
|
||
'preview.news2.title': 'War in Zarya',
|
||
'preview.news2.body': 'Fenix attacks, Lastochka defends. Citizens pick a side — or rise up.',
|
||
'preview.settings.ram': 'Allocated RAM',
|
||
'preview.settings.resolution': 'Resolution',
|
||
'preview.settings.language': 'Language',
|
||
'preview.settings.server': 'Server',
|
||
'preview.online': 'Online',
|
||
'preview.install.title': 'Installing pack…',
|
||
'preview.install.stage': 'Downloading mods 130/130 · 1.8 GB',
|
||
'nav.login': 'Sign in',
|
||
'nav.cabinet': 'Cabinet',
|
||
'nav.logout': 'Logout',
|
||
'auth.title': 'Sign in',
|
||
'auth.login': 'Sign in',
|
||
'auth.register': 'Sign up',
|
||
'auth.username': 'Username',
|
||
'auth.password': 'Password',
|
||
'auth.confirm': 'Confirm password',
|
||
'auth.error': 'Error',
|
||
'cabinet.title': 'Personal <span class="accent">cabinet.</span>',
|
||
'cabinet.sub': 'Your profile and available packs',
|
||
'cabinet.profile': 'Profile',
|
||
'cabinet.packs': 'Packs',
|
||
'cabinet.pass': 'Pass',
|
||
'cabinet.available': 'Available packs',
|
||
'cabinet.downloading': 'Downloading...',
|
||
'cabinet.needPass': 'Pass required',
|
||
'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' ? 'RU' : 'EN';
|
||
|
||
// Section indices: add a tooltip explaining what "02 / 07" means.
|
||
document.querySelectorAll('.section-index').forEach((el) => {
|
||
const m = (el.textContent || '').trim().match(/^(\d+)\s*\/\s*(\d+)$/);
|
||
if (m) el.setAttribute('title', tr('section.of').replace('{n}', m[1]).replace('{m}', m[2]));
|
||
});
|
||
|
||
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);
|
||
}
|
||
const caption = document.getElementById('previewCaption');
|
||
if (caption && caption.dataset.screen) {
|
||
const scene = SCENES.find((s) => s.key === caption.dataset.screen);
|
||
caption.textContent = scene ? tr(scene.label) : '';
|
||
}
|
||
}
|
||
|
||
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'];
|
||
let i = 0;
|
||
let n = bytes;
|
||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||
return n.toFixed(1) + ' ' + units[i];
|
||
}
|
||
|
||
async function loadLauncherInfo() {
|
||
try {
|
||
const res = await fetch(API + '/launcher/info');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const version = data.current_version || '—';
|
||
|
||
const heroVersion = document.getElementById('hero-version');
|
||
const heroSize = document.getElementById('hero-size');
|
||
const dlVersion = document.getElementById('download-version');
|
||
const dlSize = document.getElementById('download-size');
|
||
const dlBtn = document.getElementById('download-btn');
|
||
const dlOnline = document.getElementById('download-online-btn');
|
||
const dlOffline = document.getElementById('download-offline-btn');
|
||
const pvVersion = document.getElementById('preview-version');
|
||
|
||
if (heroVersion) heroVersion.textContent = version;
|
||
if (dlVersion) dlVersion.textContent = version;
|
||
if (pvVersion) pvVersion.textContent = 'v' + version;
|
||
|
||
if (data.files && data.files.zips && data.files.zips.length) {
|
||
const latest = data.files.zips[0];
|
||
if (heroSize) heroSize.textContent = fmtSize(latest.size);
|
||
if (dlSize) dlSize.textContent = fmtSize(latest.size);
|
||
}
|
||
|
||
if (dlBtn && data.new_format && data.new_format.download_url) {
|
||
dlBtn.href = API + data.new_format.download_url;
|
||
}
|
||
// Online/Offline hybrid (Go, 1.1.3)
|
||
if (dlOnline) {
|
||
// online setup is Go exe (~5.8M) — try to discover via /launcher/version or static name
|
||
try {
|
||
const v = version && version !== '—' ? version : '1.1.1.2';
|
||
dlOnline.href = API + '/launcher/download/online';
|
||
dlOnline.setAttribute('data-version', v);
|
||
} catch(e){}
|
||
}
|
||
if (dlOffline) dlOffline.href = API + '/launcher/download/offline-setup';
|
||
if (dlOffline && data.new_format && data.new_format.download_url) {
|
||
}
|
||
} catch (e) {
|
||
console.warn('[site] failed to load launcher info', e);
|
||
}
|
||
}
|
||
|
||
async function loadMirrors() {
|
||
try {
|
||
const res = await fetch(API + '/launcher/mirrors');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const container = document.getElementById('mirrors');
|
||
if (!container || !data.mirrors || !data.mirrors.length) return;
|
||
|
||
const tags = data.mirrors.slice(0, 5).map((m) => {
|
||
const a = document.createElement('a');
|
||
a.className = 'mirror-tag';
|
||
a.href = (m.url || '') + '/launcher/download/latest';
|
||
a.textContent = m.name || m;
|
||
return a;
|
||
});
|
||
tags.forEach((t) => container.appendChild(t));
|
||
} catch (e) {
|
||
console.warn('[site] failed to load mirrors', e);
|
||
}
|
||
}
|
||
|
||
async function loadServerStatus() {
|
||
const statusEl = document.getElementById('srv-status');
|
||
const playersEl = document.getElementById('srv-players');
|
||
const versionEl = document.getElementById('srv-version');
|
||
if (!statusEl) return;
|
||
|
||
try {
|
||
const res = await fetch(API + '/api/status');
|
||
const data = await res.json();
|
||
|
||
if (data.online) {
|
||
statusEl.dataset.state = 'online';
|
||
if (playersEl) playersEl.textContent = `${data.players_online} / ${data.players_max}`;
|
||
if (versionEl && data.version) versionEl.textContent = data.version;
|
||
} else {
|
||
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 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', 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() {
|
||
const track = document.getElementById('carousel');
|
||
const dotsWrap = document.getElementById('carouselDots');
|
||
const prev = document.getElementById('carouselPrev');
|
||
const next = document.getElementById('carouselNext');
|
||
if (!track) return;
|
||
|
||
const AUTO_MS = 6500; // auto-advance when idle
|
||
const MANUAL_MS = 22000; // hold much longer after manual navigation
|
||
const videos = [];
|
||
|
||
HIGHLIGHTS.forEach((h, i) => {
|
||
const slide = document.createElement('div');
|
||
slide.className = 'carousel-slide';
|
||
|
||
if (h.type === 'video') {
|
||
const vid = document.createElement('video');
|
||
vid.src = '/highlights/' + h.file;
|
||
vid.controls = true;
|
||
vid.muted = true;
|
||
vid.playsInline = true;
|
||
slide.appendChild(vid);
|
||
videos[i] = vid;
|
||
} else {
|
||
const img = document.createElement('img');
|
||
img.src = '/highlights/' + h.file;
|
||
img.alt = h.captionKey ? tr(h.captionKey) : tr('carousel.alt');
|
||
img.loading = 'lazy';
|
||
slide.appendChild(img);
|
||
}
|
||
|
||
if (h.captionKey) {
|
||
const cap = document.createElement('span');
|
||
cap.className = 'carousel-caption';
|
||
cap.dataset.captionKey = h.captionKey;
|
||
cap.textContent = tr(h.captionKey);
|
||
slide.appendChild(cap);
|
||
}
|
||
|
||
track.appendChild(slide);
|
||
|
||
const dot = document.createElement('span');
|
||
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||
dot.addEventListener('click', () => goto(i, true));
|
||
dotsWrap.appendChild(dot);
|
||
});
|
||
|
||
let index = 0;
|
||
let timer = null;
|
||
|
||
function schedule(delay) {
|
||
stop();
|
||
timer = setInterval(() => nextSlide(false), delay);
|
||
}
|
||
|
||
function isVideoAt(i) {
|
||
const h = HIGHLIGHTS[i];
|
||
return h && h.type === 'video';
|
||
}
|
||
|
||
function goto(i, manual) {
|
||
index = (i + HIGHLIGHTS.length) % HIGHLIGHTS.length;
|
||
track.style.transform = 'translateX(-' + index * 100 + '%)';
|
||
dotsWrap.querySelectorAll('.carousel-dot').forEach((d, k) => {
|
||
d.classList.toggle('active', k === index);
|
||
});
|
||
|
||
// Only the visible video plays; others pause to save bandwidth.
|
||
videos.forEach((vid, k) => {
|
||
if (vid) {
|
||
if (k === index) {
|
||
if (vid.paused) {
|
||
vid.currentTime = 0;
|
||
const p = vid.play();
|
||
if (p && p.catch) p.catch(() => {});
|
||
}
|
||
} else {
|
||
vid.pause();
|
||
}
|
||
}
|
||
});
|
||
|
||
if (isVideoAt(index)) {
|
||
// A video slide always advances on its 'ended' event, never on a timer.
|
||
stop();
|
||
return;
|
||
}
|
||
// Image slides: manual navigation holds much longer, idle auto-advance restores.
|
||
schedule(manual ? MANUAL_MS : AUTO_MS);
|
||
}
|
||
|
||
function nextSlide(manual) { goto(index + 1, !!manual); }
|
||
function prevSlide() { goto(index - 1, true); }
|
||
|
||
function start() { goto(index, false); }
|
||
function stop() {
|
||
if (timer) { clearInterval(timer); timer = null; }
|
||
}
|
||
|
||
// A video slide advances only once its playback actually finished.
|
||
videos.forEach((vid) => {
|
||
if (!vid) return;
|
||
vid.addEventListener('ended', () => nextSlide(false));
|
||
});
|
||
|
||
if (prev) prev.addEventListener('click', () => { prevSlide(); });
|
||
if (next) next.addEventListener('click', () => { nextSlide(true); });
|
||
track.addEventListener('mouseenter', stop);
|
||
track.addEventListener('mouseleave', start);
|
||
|
||
goto(0, false);
|
||
}
|
||
|
||
function stripMarkup(text) {
|
||
if (!text) return '';
|
||
return text
|
||
.replace(/\[[^\]]*photo=[^\]]*\]/g, '')
|
||
.replace(/\[[^\]]*\]/g, '')
|
||
.replace(/\*\*\*/g, '')
|
||
.replace(/\*\*/g, '')
|
||
.replace(/[*_~`#]/g, '')
|
||
.replace(/\n+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
async function loadNews() {
|
||
const grid = document.getElementById('news-grid');
|
||
if (!grid) return;
|
||
|
||
try {
|
||
const res = await fetch(API + '/news');
|
||
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">' + tr('news.empty') + '</p>'; return; }
|
||
|
||
grid.innerHTML = '';
|
||
news.forEach((n) => {
|
||
const card = document.createElement('article');
|
||
card.className = 'news-card';
|
||
|
||
const tags = document.createElement('div');
|
||
tags.className = 'news-tags';
|
||
if (n.type) {
|
||
const t = document.createElement('span');
|
||
t.className = 'news-tag';
|
||
t.textContent = n.type;
|
||
tags.appendChild(t);
|
||
}
|
||
if (n.version) {
|
||
const v = document.createElement('span');
|
||
v.className = 'news-tag news-tag--version';
|
||
v.textContent = n.version;
|
||
tags.appendChild(v);
|
||
}
|
||
|
||
const title = document.createElement('h3');
|
||
title.textContent = n.title || tr('news.update');
|
||
|
||
const body = document.createElement('p');
|
||
const text = stripMarkup(n.body);
|
||
body.textContent = text.length > 220 ? text.slice(0, 220) + '…' : text;
|
||
|
||
card.appendChild(tags);
|
||
card.appendChild(title);
|
||
card.appendChild(body);
|
||
grid.appendChild(card);
|
||
});
|
||
} catch (e) {
|
||
console.warn('[site] failed to load news', e);
|
||
grid.innerHTML = '<p class="section-sub">' + tr('news.error') + '</p>';
|
||
}
|
||
}
|
||
|
||
function initNav() {
|
||
const nav = document.getElementById('nav');
|
||
const onScroll = () => nav.classList.toggle('scrolled', window.scrollY > 24);
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
onScroll();
|
||
}
|
||
|
||
function initParallax() {
|
||
const els = document.querySelectorAll('[data-parallax]');
|
||
if (!els.length) return;
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
|
||
const onScroll = () => {
|
||
const y = window.scrollY;
|
||
if (y > window.innerHeight) return;
|
||
els.forEach((el) => {
|
||
const speed = parseFloat(el.dataset.parallax);
|
||
el.style.transform = 'translateY(' + (y * speed) + 'px)';
|
||
});
|
||
};
|
||
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
onScroll();
|
||
}
|
||
|
||
const SCENES = [
|
||
{ key: 'login', label: 'preview.login' },
|
||
{ key: 'packs', label: 'preview.packs' },
|
||
{ key: 'news', label: 'preview.news' },
|
||
{ key: 'settings', label: 'preview.settings' },
|
||
{ key: 'install', label: 'preview.install' },
|
||
];
|
||
|
||
let sceneIndex = 0;
|
||
let sceneTimer = null;
|
||
|
||
function showScene(i) {
|
||
const body = document.getElementById('launcherPreviewBody');
|
||
const dots = document.getElementById('previewDots');
|
||
const caption = document.getElementById('previewCaption');
|
||
if (!body) return;
|
||
|
||
sceneIndex = (i + SCENES.length) % SCENES.length;
|
||
body.querySelectorAll('.preview-scene').forEach((el, k) => {
|
||
el.classList.toggle('is-active', k === sceneIndex);
|
||
});
|
||
if (dots) {
|
||
dots.querySelectorAll('.preview-dot').forEach((d, k) => {
|
||
d.classList.toggle('active', k === sceneIndex);
|
||
});
|
||
}
|
||
if (caption) {
|
||
caption.textContent = tr(SCENES[sceneIndex].label);
|
||
caption.dataset.screen = SCENES[sceneIndex].key;
|
||
}
|
||
}
|
||
|
||
function nextScene() { showScene(sceneIndex + 1); }
|
||
|
||
function startPreview() {
|
||
if (!document.getElementById('launcherPreviewBody')) return;
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
if (sceneTimer) clearInterval(sceneTimer);
|
||
sceneTimer = setInterval(nextScene, 4200);
|
||
showScene(0);
|
||
}
|
||
|
||
function initPreview() {
|
||
const body = document.getElementById('launcherPreviewBody');
|
||
const dots = document.getElementById('previewDots');
|
||
if (!body || !dots) return;
|
||
|
||
SCENES.forEach((_, k) => {
|
||
const d = document.createElement('span');
|
||
d.className = 'preview-dot' + (k === 0 ? ' active' : '');
|
||
dots.appendChild(d);
|
||
});
|
||
|
||
startPreview();
|
||
}
|
||
|
||
function initReveal() {
|
||
const els = document.querySelectorAll('.section-head, .feature-card, .download-card, .news-card, .server-card, .theme-card, .carousel, .launcher-window');
|
||
if (!('IntersectionObserver' in window)) {
|
||
els.forEach((el) => el.classList.add('visible'));
|
||
return;
|
||
}
|
||
const io = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.classList.add('visible');
|
||
io.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { threshold: 0.12 });
|
||
els.forEach((el) => { el.classList.add('reveal'); io.observe(el); });
|
||
}
|
||
|
||
function initYear() {
|
||
const el = document.getElementById('year');
|
||
if (el) el.textContent = new Date().getFullYear();
|
||
}
|
||
|
||
async function loadGithubLanguages() {
|
||
const bar = document.getElementById('lang-bar');
|
||
const legend = document.getElementById('lang-legend');
|
||
if (!bar || !legend) return;
|
||
const colors = { Java:'#b07219', Python:'#3572A5', JavaScript:'#f1e05a', TypeScript:'#3178c6', CSS:'#563d7c', HTML:'#e34c26', Go:'#00ADD8', Rust:'#dea584', Shell:'#89e051', Dockerfile:'#384d54' };
|
||
const tryFetch = async (url) => {
|
||
const r = await fetch(url, {headers:{'Accept':'application/vnd.github.v3+json'}});
|
||
if (!r.ok) throw new Error(url);
|
||
return r.json();
|
||
};
|
||
try {
|
||
let data = null;
|
||
try { data = await tryFetch('https://git.swe.zernmc.ru/api/v1/repos/sasheg/launcher/languages'); } catch(e) {
|
||
// Gitea fallback only, GitHub mirror removed
|
||
}
|
||
if (!data || typeof data !== 'object') return;
|
||
const total = Object.values(data).reduce((a,b)=>a+b,0);
|
||
if (!total) return;
|
||
const entries = Object.entries(data).map(([lang, bytes])=>({lang, bytes, pct: bytes/total*100})).sort((a,b)=>b.bytes-a.bytes).slice(0,6);
|
||
// update bar
|
||
bar.innerHTML = '';
|
||
bar.title = entries.map(e=>`${e.lang} ${e.pct.toFixed(1)}%`).join(' · ');
|
||
legend.innerHTML = '';
|
||
entries.forEach(e=>{
|
||
const seg = document.createElement('div');
|
||
seg.style.flex = String(e.pct);
|
||
seg.style.background = colors[e.lang] || 'var(--accent)';
|
||
seg.title = `${e.lang} ${e.pct.toFixed(1)}%`;
|
||
bar.appendChild(seg);
|
||
const item = document.createElement('span');
|
||
item.style.display='flex'; item.style.alignItems='center'; item.style.gap='6px';
|
||
item.innerHTML = `<i style="width:10px;height:10px;border-radius:50%;background:${colors[e.lang]||'var(--accent)'};display:inline-block"></i> ${e.lang} ${e.pct.toFixed(1)}%`;
|
||
legend.appendChild(item);
|
||
});
|
||
} catch(e) { /* keep static fallback */ }
|
||
}
|
||
|
||
// ====================== AUTH (site, launcher style) ======================
|
||
const AUTH_KEY = 'zern_auth';
|
||
let authMode = 'login'; // login | register
|
||
|
||
function getAuth() {
|
||
try { return JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch(e) { return null; }
|
||
}
|
||
function setAuth(data) {
|
||
try { localStorage.setItem(AUTH_KEY, JSON.stringify(data)); } catch(e) {}
|
||
updateAuthUI();
|
||
}
|
||
function clearAuth() {
|
||
try { localStorage.removeItem(AUTH_KEY); } catch(e) {}
|
||
updateAuthUI();
|
||
}
|
||
function authHeaders() {
|
||
const a = getAuth();
|
||
return a && a.access_token ? { 'Authorization': 'Bearer ' + a.access_token } : {};
|
||
}
|
||
function updateAuthUI() {
|
||
const a = getAuth();
|
||
const btn = document.getElementById('auth-btn');
|
||
const menu = document.getElementById('user-menu');
|
||
const nameEl = document.getElementById('user-name');
|
||
if (a && a.username) {
|
||
if (btn) btn.classList.add('hidden');
|
||
if (menu) menu.classList.remove('hidden');
|
||
if (nameEl) nameEl.textContent = a.username;
|
||
} else {
|
||
if (btn) btn.classList.remove('hidden');
|
||
if (menu) menu.classList.add('hidden');
|
||
}
|
||
}
|
||
function showAuthModal(mode) {
|
||
authMode = mode || 'login';
|
||
const modal = document.getElementById('auth-modal');
|
||
const title = document.getElementById('auth-title');
|
||
const tabLogin = document.getElementById('auth-tab-login');
|
||
const tabReg = document.getElementById('auth-tab-register');
|
||
const confirmField = document.getElementById('auth-confirm-field');
|
||
const submit = document.getElementById('auth-submit');
|
||
const err = document.getElementById('auth-error');
|
||
if (err) { err.classList.add('hidden'); err.textContent=''; }
|
||
if (title) title.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login');
|
||
if (tabLogin) { tabLogin.style.background = authMode==='login' ? 'var(--accent)' : ''; tabLogin.style.color = authMode==='login' ? '#fff' : ''; }
|
||
if (tabReg) { tabReg.style.background = authMode==='register' ? 'var(--accent)' : ''; tabReg.style.color = authMode==='register' ? '#fff' : ''; }
|
||
if (confirmField) confirmField.classList.toggle('hidden', authMode!=='register');
|
||
if (submit) submit.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login');
|
||
if (modal) modal.classList.remove('hidden');
|
||
}
|
||
function hideAuthModal() {
|
||
const m = document.getElementById('auth-modal');
|
||
if (m) m.classList.add('hidden');
|
||
}
|
||
async function handleAuthSubmit() {
|
||
const u = document.getElementById('auth-username').value.trim();
|
||
const p = document.getElementById('auth-password').value;
|
||
const c = document.getElementById('auth-confirm') ? document.getElementById('auth-confirm').value : '';
|
||
const err = document.getElementById('auth-error');
|
||
const btn = document.getElementById('auth-submit');
|
||
if (!u || !p) { if (err){ err.textContent='Заполните поля'; err.classList.remove('hidden'); } return; }
|
||
if (authMode==='register' && p!==c) { if (err){ err.textContent='Пароли не совпадают'; err.classList.remove('hidden'); } return; }
|
||
if (btn) { btn.disabled=true; btn.textContent='...'; }
|
||
if (err) err.classList.add('hidden');
|
||
const endpoint = authMode==='register' ? '/auth/register' : '/auth/login';
|
||
try {
|
||
const r = await fetch(API + endpoint, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username:u, password:p}) });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.detail || data.message || 'Ошибка');
|
||
// TokenResponse {access_token, refresh_token, username, uuid, role, role_name}
|
||
setAuth(data);
|
||
hideAuthModal();
|
||
// after login, refresh cabinet if open
|
||
if (!document.getElementById('cabinet').classList.contains('hidden')) loadCabinetPacks();
|
||
// also check pass
|
||
try { const pr = await fetch(API + '/auth/pass/my', { headers: authHeaders() }); const pd = await pr.json(); if (pd.has_active) { /* pass active */ } } catch(e){}
|
||
} catch(e) {
|
||
if (err){ err.textContent = e.message || 'Ошибка'; err.classList.remove('hidden'); }
|
||
} finally { if (btn) { btn.disabled=false; btn.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login'); } }
|
||
}
|
||
async function handleLogout() {
|
||
const a = getAuth();
|
||
try { if (a) await fetch(API + '/auth/logout', { method:'POST', headers: authHeaders() }); } catch(e) {}
|
||
clearAuth();
|
||
// hide cabinet
|
||
document.getElementById('cabinet').classList.add('hidden');
|
||
}
|
||
async function loadCabinetProfile() {
|
||
const a = getAuth();
|
||
if (!a) return;
|
||
// try to get role/pass via /auth/pass/my and /admin/me or via token payload
|
||
const cabUser = document.getElementById('cab-username');
|
||
const cabAvatar = document.getElementById('cab-avatar');
|
||
const cabRole = document.getElementById('cab-role');
|
||
const cabUuid = document.getElementById('cab-uuid');
|
||
const cabPass = document.getElementById('cab-pass');
|
||
const cabPass2 = document.getElementById('cab-pass2');
|
||
if (cabUser) cabUser.textContent = a.username || '—';
|
||
if (cabAvatar) cabAvatar.textContent = (a.username||'Z')[0].toUpperCase();
|
||
if (cabUuid) cabUuid.textContent = a.uuid || '—';
|
||
if (cabRole) cabRole.textContent = a.role_name || ('Role '+ (a.role||0));
|
||
// pass check
|
||
try {
|
||
const r = await fetch(API + '/auth/pass/my', { headers: authHeaders() });
|
||
const d = await r.json();
|
||
const has = !!d.has_active;
|
||
if (cabPass) cabPass.textContent = has ? 'Активна' : 'Нет';
|
||
if (cabPass2) { cabPass2.textContent = has ? 'Активна' : 'Нет'; cabPass2.style.color = has ? 'var(--success)' : 'var(--error)'; }
|
||
} catch(e) {
|
||
if (cabPass) cabPass.textContent = '—';
|
||
}
|
||
}
|
||
async function loadCabinetPacks() {
|
||
const list = document.getElementById('cab-packs-list');
|
||
const count = document.getElementById('cab-packs-count');
|
||
const errEl = document.getElementById('cab-packs-error');
|
||
if (!list) return;
|
||
list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">Загрузка...</p>';
|
||
if (errEl) errEl.classList.add('hidden');
|
||
const a = getAuth();
|
||
if (!a) { list.innerHTML = '<p style="color:var(--text-muted)">Войдите, чтобы увидеть сборки</p>'; return; }
|
||
try {
|
||
const r = await fetch(API + '/packs', { headers: authHeaders() });
|
||
if (r.status===401 || r.status===403) throw new Error('Нужна проходка или вход');
|
||
if (!r.ok) throw new Error('Не удалось загрузить');
|
||
const data = await r.json();
|
||
const packs = data.packs || [];
|
||
if (count) count.textContent = packs.length + ' шт.';
|
||
if (!packs.length) { list.innerHTML = '<p style="color:var(--text-muted)">Нет доступных сборок</p>'; return; }
|
||
list.innerHTML = '';
|
||
packs.forEach(p=>{
|
||
const el = document.createElement('div');
|
||
el.style.cssText='display:flex;align-items:center;gap:12px;padding:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:10px';
|
||
el.innerHTML = `
|
||
<div style="width:36px;height:36px;border-radius:8px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||
</div>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:13px">${p.name} <span style="font-weight:400;color:var(--text-muted)">v${p.version||''}</span></div>
|
||
<div style="font-size:12px;color:var(--text-secondary)">${p.minecraft_version||''} · ${p.loader_type||''} ${p.loader_version||''} · ${p.files_count||0} файлов</div>
|
||
</div>
|
||
<button class="btn btn-primary" style="padding:8px 14px;font-size:13px;flex-shrink:0" data-pack="${p.name}">Скачать</button>
|
||
`;
|
||
const btn = el.querySelector('button');
|
||
btn.addEventListener('click', ()=> downloadPack(p.name, btn));
|
||
list.appendChild(el);
|
||
});
|
||
} catch(e) {
|
||
if (errEl){ errEl.textContent = e.message; errEl.classList.remove('hidden'); }
|
||
list.innerHTML = '';
|
||
}
|
||
}
|
||
async function downloadPack(name, btn) {
|
||
const orig = btn.textContent;
|
||
btn.disabled = true; btn.textContent = tr('cabinet.downloading');
|
||
try {
|
||
const r = await fetch(API + '/pack/' + encodeURIComponent(name) + '/zip', { headers: authHeaders() });
|
||
if (!r.ok) {
|
||
const t = await r.text();
|
||
let msg = t;
|
||
try { const j = JSON.parse(t); msg = j.detail || j.message || t; } catch(e){}
|
||
throw new Error(msg || ('HTTP '+r.status));
|
||
}
|
||
const blob = await r.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name + '.zip';
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
URL.revokeObjectURL(url);
|
||
} catch(e) {
|
||
alert(e.message);
|
||
} finally { btn.disabled=false; btn.textContent=orig; }
|
||
}
|
||
function initAuth() {
|
||
updateAuthUI();
|
||
const authBtn = document.getElementById('auth-btn');
|
||
const cabinetBtn = document.getElementById('cabinet-btn');
|
||
const logoutBtn = document.getElementById('logout-btn');
|
||
const authModal = document.getElementById('auth-modal');
|
||
const authClose = document.getElementById('auth-close');
|
||
const tabLogin = document.getElementById('auth-tab-login');
|
||
const tabReg = document.getElementById('auth-tab-register');
|
||
const submit = document.getElementById('auth-submit');
|
||
const cabTabProfile = document.getElementById('cab-tab-profile');
|
||
const cabTabPacks = document.getElementById('cab-tab-packs');
|
||
if (authBtn) authBtn.addEventListener('click', ()=> showAuthModal('login'));
|
||
if (cabinetBtn) cabinetBtn.addEventListener('click', ()=>{
|
||
const cab = document.getElementById('cabinet');
|
||
if (cab) { cab.classList.remove('hidden'); cab.scrollIntoView({behavior:'smooth'}); loadCabinetProfile(); loadCabinetPacks(); }
|
||
});
|
||
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
|
||
if (authClose) authClose.addEventListener('click', hideAuthModal);
|
||
if (authModal) authModal.addEventListener('click', e=>{ if(e.target===authModal) hideAuthModal(); });
|
||
if (tabLogin) tabLogin.addEventListener('click', ()=> showAuthModal('login'));
|
||
if (tabReg) tabReg.addEventListener('click', ()=> showAuthModal('register'));
|
||
if (submit) submit.addEventListener('click', handleAuthSubmit);
|
||
// enter key
|
||
['auth-username','auth-password','auth-confirm'].forEach(id=>{
|
||
const el=document.getElementById(id);
|
||
if(el) el.addEventListener('keydown', e=>{ if(e.key==='Enter') handleAuthSubmit(); });
|
||
});
|
||
if (cabTabProfile) cabTabProfile.addEventListener('click', ()=>{
|
||
document.getElementById('cabinet-profile').classList.remove('hidden');
|
||
document.getElementById('cabinet-packs').classList.add('hidden');
|
||
cabTabProfile.classList.add('btn-primary'); cabTabProfile.classList.remove('btn-ghost');
|
||
cabTabPacks.classList.remove('btn-primary'); cabTabPacks.classList.add('btn-ghost');
|
||
});
|
||
if (cabTabPacks) cabTabPacks.addEventListener('click', ()=>{
|
||
document.getElementById('cabinet-profile').classList.add('hidden');
|
||
document.getElementById('cabinet-packs').classList.remove('hidden');
|
||
cabTabPacks.classList.add('btn-primary'); cabTabPacks.classList.remove('btn-ghost');
|
||
cabTabProfile.classList.remove('btn-primary'); cabTabProfile.classList.add('btn-ghost');
|
||
loadCabinetPacks();
|
||
});
|
||
// also make manual Prologue button use auth download (override default link)
|
||
const manualBtn = document.querySelector('a[href="/pack/ZernPrologue/zip"]');
|
||
if (manualBtn) {
|
||
manualBtn.addEventListener('click', async (e)=>{
|
||
const a = getAuth();
|
||
if (!a) { e.preventDefault(); showAuthModal('login'); return; }
|
||
e.preventDefault();
|
||
// reuse downloadPack
|
||
const fakeBtn = { disabled:false, textContent: manualBtn.textContent };
|
||
// temporarily set text
|
||
const orig = manualBtn.innerHTML;
|
||
manualBtn.textContent = tr('cabinet.downloading');
|
||
try {
|
||
const r = await fetch(API + '/pack/ZernPrologue/zip', { headers: authHeaders() });
|
||
if (!r.ok) throw new Error((await r.text()).slice(0,120));
|
||
const blob = await r.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a2 = document.createElement('a'); a2.href=url; a2.download='ZernPrologue.zip'; document.body.appendChild(a2); a2.click(); a2.remove(); URL.revokeObjectURL(url);
|
||
} catch(err){ alert(err.message); }
|
||
manualBtn.innerHTML = orig;
|
||
});
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
applyI18n();
|
||
|
||
const toggle = document.getElementById('langToggle');
|
||
if (toggle) toggle.addEventListener('click', toggleLang);
|
||
|
||
loadLauncherInfo();
|
||
loadMirrors();
|
||
loadNews();
|
||
loadServerStatus();
|
||
setInterval(loadServerStatus, 15000);
|
||
loadGithubLanguages();
|
||
initCarousel();
|
||
initPreview();
|
||
initNav();
|
||
initParallax();
|
||
initReveal();
|
||
initYear();
|
||
initAuth();
|
||
// if already authed, preload cabinet data
|
||
if (getAuth()) loadCabinetProfile();
|
||
});
|
||
})();
|