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:
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user