diff --git a/server/site/css/style.css b/server/site/css/style.css index ff1b44b..38075b2 100644 --- a/server/site/css/style.css +++ b/server/site/css/style.css @@ -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; } diff --git a/server/site/index.html b/server/site/index.html index fb68d37..56082ae 100644 --- a/server/site/index.html +++ b/server/site/index.html @@ -33,6 +33,12 @@ EN + + Играть сейчас @@ -505,6 +511,72 @@ + + + + + + diff --git a/server/site/js/main.js b/server/site/js/main.js index c601ecb..b162278 100644 --- a/server/site/js/main.js +++ b/server/site/js/main.js @@ -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': 'Личный кабинет.', + 'cabinet.sub': 'Ваш профиль и доступные сборки', + 'cabinet.profile': 'Профиль', + 'cabinet.packs': 'Сборки', + 'cabinet.pass': 'Проходка', + 'cabinet.available': 'Доступные сборки', + 'cabinet.downloading': 'Скачивание...', + 'cabinet.needPass': 'Нужна проходка', 'news.title': 'Что нового.', '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 cabinet.', + '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 new.', '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 = '

Загрузка...

'; + if (errEl) errEl.classList.add('hidden'); + const a = getAuth(); + if (!a) { list.innerHTML = '

Войдите, чтобы увидеть сборки

'; 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 = '

Нет доступных сборок

'; 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 = ` +
+ +
+
+
${p.name} v${p.version||''}
+
${p.minecraft_version||''} · ${p.loader_type||''} ${p.loader_version||''} · ${p.files_count||0} файлов
+
+ + `; + 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(); }); })();