site: add launcher-style auth (login/register modal, JWT localStorage) + cabinet with Сборки tab (list /packs, download /pack/{name}/zip with Bearer, pass check, manual Prologue intercept)

This commit is contained in:
SashegDev
2026-09-02 04:35:57 +00:00
parent 146a56cdff
commit 025a21d01f
3 changed files with 364 additions and 0 deletions
+15
View File
@@ -1304,6 +1304,21 @@ ul { list-style: none; }
.pv-sb-section { margin-top: 6px; }
}
/* ========== Auth & Cabinet (launcher style) ========== */
.hidden { display: none !important; }
.modal-backdrop { position:fixed; inset:0; background:rgba(7,7,10,0.72); backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; z-index:100; animation:fadeIn 0.2s ease; }
.modal { background:var(--bg-surface, #0c0c12); border:1px solid var(--border, rgba(255,255,255,0.08)); border-radius:16px; width:90%; max-width:420px; box-shadow:0 20px 60px rgba(0,0,0,0.6); overflow:hidden; }
.modal-head { display:flex; align-items:center; justify-content:space-between; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-head h3 { font-size:15px; font-weight:700; }
.modal-close { background:transparent; border:none; color:var(--text-secondary); font-size:20px; cursor:pointer; width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:8px; }
.modal-close:hover { background:var(--bg-card); color:var(--text); }
.modal-body { padding:20px; }
.field { display:flex; flex-direction:column; gap:6px; }
.field label { font-size:12px; color:var(--text-secondary); }
.field input { width:100%; padding:10px 12px; background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; color:var(--text); outline:none; }
.field input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(233,69,96,0.15); }
.cabinet-tabs { display:flex; gap:8px; margin-bottom:12px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
html { scroll-behavior: auto; }
+72
View File
@@ -33,6 +33,12 @@
<span class="lang-toggle-label" id="langToggleLabel">EN</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</button>
<button class="btn btn-ghost" id="auth-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.login">Войти</button>
<div id="user-menu" class="hidden" style="display:flex;align-items:center;gap:8px">
<span id="user-name" style="font-weight:600;font-size:13px;color:var(--text)"></span>
<button class="btn btn-ghost" id="cabinet-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.cabinet">Кабинет</button>
<button class="btn btn-ghost" id="logout-btn" style="padding:8px 14px;font-size:13px" data-i18n="nav.logout">Выйти</button>
</div>
<a class="nav-cta" href="#download" data-i18n="nav.play">Играть сейчас</a>
</div>
</div>
@@ -505,6 +511,72 @@
</div>
</footer>
<!-- Auth modal (launcher style) -->
<div id="auth-modal" class="modal-backdrop hidden" style="z-index:200">
<div class="modal" style="max-width:380px">
<div class="modal-head">
<h3 id="auth-title" data-i18n="auth.title">Вход</h3>
<button class="modal-close" id="auth-close">&times;</button>
</div>
<div class="modal-body" style="display:flex;flex-direction:column;gap:14px">
<div style="display:flex;gap:8px;margin-bottom:4px">
<button class="btn btn-ghost" id="auth-tab-login" style="flex:1;background:var(--accent);color:#fff" data-i18n="auth.login">Вход</button>
<button class="btn btn-ghost" id="auth-tab-register" style="flex:1" data-i18n="auth.register">Регистрация</button>
</div>
<div class="field">
<label for="auth-username" data-i18n="auth.username">Логин</label>
<input type="text" id="auth-username" placeholder="a-z, 0-9, _" autocomplete="username" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field">
<label for="auth-password" data-i18n="auth.password">Пароль</label>
<input type="password" id="auth-password" placeholder="••••••••" autocomplete="current-password" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<div class="field hidden" id="auth-confirm-field">
<label for="auth-confirm" data-i18n="auth.confirm">Повтор пароля</label>
<input type="password" id="auth-confirm" style="width:100%;padding:10px 12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;color:var(--text);outline:none">
</div>
<p id="auth-error" class="hidden" style="color:#f87171;font-size:13px;background:rgba(248,113,113,0.1);padding:10px;border-radius:4px;text-align:center"></p>
<button class="btn btn-primary" id="auth-submit" style="width:100%">Войти</button>
<p style="font-size:12px;color:var(--text-muted);text-align:center">Нажимая, вы соглашаетесь с правилами ZernMC</p>
</div>
</div>
</div>
<!-- Cabinet (personal area) -->
<section class="section hidden" id="cabinet">
<div class="section-head">
<span class="section-index">08 / 08</span>
<h2 class="section-title" data-i18n-html="cabinet.title">Личный <span class="accent">кабинет.</span></h2>
<p class="section-sub" data-i18n="cabinet.sub">Ваш профиль и доступные сборки для скачивания (требует проходку).</p>
</div>
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<button class="btn btn-primary" id="cab-tab-profile" data-i18n="cabinet.profile">Профиль</button>
<button class="btn btn-ghost" id="cab-tab-packs" data-i18n="cabinet.packs">Сборки</button>
</div>
<div id="cabinet-profile" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:48px;height:48px;border-radius:12px;background:linear-gradient(135deg, #e94560, #ff6b6b);display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff" id="cab-avatar">Z</div>
<div>
<div style="font-weight:700" id="cab-username"></div>
<div style="font-size:12px;color:var(--text-secondary)"><span id="cab-role"></span> · <span id="cab-pass"></span></div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4>UUID</h4><p id="cab-uuid" style="font-family:var(--mono);font-size:11px"></p></div></div>
<div class="setting-card" style="flex:1;min-width:180px;padding:12px"><div class="setting-info"><h4 data-i18n="cabinet.pass">Проходка</h4><p id="cab-pass2"></p></div></div>
</div>
</div>
<div id="cabinet-packs" class="hidden" style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:20px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h3 style="font-size:15px;font-weight:700" data-i18n="cabinet.available">Доступные сборки</h3>
<span id="cab-packs-count" style="font-size:12px;color:var(--text-muted)"></span>
</div>
<div id="cab-packs-list" style="display:flex;flex-direction:column;gap:8px"><p style="color:var(--text-muted);font-size:13px">Загрузка...</p></div>
<p id="cab-packs-error" class="hidden" style="color:#f87171;font-size:13px"></p>
<p style="font-size:12px;color:var(--text-muted)">Скачивание требует активной проходки. Кнопка скачает zip для ручной установки в <code>%USERPROFILE%\.zernmc\instances</code></p>
</div>
</section>
<script src="/js/main.js"></script>
</body>
</html>
+277
View File
@@ -111,6 +111,24 @@
'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': 'Новостей пока нет.',
@@ -225,6 +243,24 @@
'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.',
@@ -752,6 +788,244 @@
} 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();
@@ -770,5 +1044,8 @@
initParallax();
initReveal();
initYear();
initAuth();
// if already authed, preload cabinet data
if (getAuth()) loadCabinetProfile();
});
})();