1900db3caf
- server/main.py BUILDS_DIR absolute + /launcher/download/online,offine-setup - site index/js online->/download/online 5.8M - LaunchCommandBuilder skip G1GC when systemBasedJvm (ZGC conflict -> Bootstrap crash) - JFXLauncher toggle robust rename + FileAlreadyExists fix
775 lines
35 KiB
JavaScript
775 lines
35 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 ГБ',
|
||
'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',
|
||
'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 */ }
|
||
}
|
||
|
||
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();
|
||
});
|
||
})();
|