baton/frontend/app.js

523 lines
15 KiB
JavaScript
Raw Normal View History

2026-03-20 21:09:05 +02:00
'use strict';
// ========== Storage abstraction (decisions #1024, #1025) ==========
//
// #1024: probe availability with a real write, NOT typeof/in
// #1025: fallback chain: localStorage → sessionStorage → in-memory
let _storage = null;
let _storageType = 'memory';
const _mem = {};
function _probeStorage(s) {
try {
const k = '__baton_probe__';
s.setItem(k, '1');
s.removeItem(k);
return true;
} catch (_) {
return false;
}
}
function _initStorage() {
if (_probeStorage(localStorage)) {
_storage = localStorage;
_storageType = 'local';
} else if (_probeStorage(sessionStorage)) {
_storage = sessionStorage;
_storageType = 'session';
} else {
_storage = {
getItem: (k) => (Object.prototype.hasOwnProperty.call(_mem, k) ? _mem[k] : null),
setItem: (k, v) => { _mem[k] = String(v); },
removeItem: (k) => { delete _mem[k]; },
};
_storageType = 'memory';
}
}
// ========== User identity ==========
function _isRegistered() {
return !!_storage.getItem('baton_auth_token');
2026-03-20 21:09:05 +02:00
}
function _getUserName() {
return _storage.getItem('baton_login') || '';
}
function _getAuthToken() {
return _storage.getItem('baton_auth_token') || '';
2026-03-20 21:09:05 +02:00
}
function _saveAuth(token, login) {
_storage.setItem('baton_auth_token', token);
_storage.setItem('baton_login', login);
2026-03-21 08:13:14 +02:00
}
function _clearAuth() {
_storage.removeItem('baton_auth_token');
_storage.removeItem('baton_login');
2026-03-20 21:09:05 +02:00
}
function _getInitials(name) {
const trimmed = (name || '').trim();
if (!trimmed) return '?';
return trimmed
.split(/\s+/)
.map((w) => w[0] || '')
.join('')
.toUpperCase()
.slice(0, 2);
}
// ========== UI helpers ==========
function _showScreen(id) {
document.querySelectorAll('.screen').forEach((el) => {
el.hidden = el.id !== id;
});
}
function _setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status' + (cls ? ' status--' + cls : '');
el.hidden = !msg;
}
function _setRegStatus(msg, cls) {
const el = document.getElementById('reg-status');
if (!el) return;
el.textContent = msg;
el.className = 'reg-status' + (cls ? ' reg-status--' + cls : '');
el.hidden = !msg;
}
function _setLoginStatus(msg, cls) {
const el = document.getElementById('login-status');
if (!el) return;
el.textContent = msg;
el.className = 'reg-status' + (cls ? ' reg-status--' + cls : '');
el.hidden = !msg;
}
function _showView(id) {
['view-login', 'view-register'].forEach((vid) => {
const el = document.getElementById(vid);
if (el) el.hidden = vid !== id;
});
}
2026-03-20 21:09:05 +02:00
function _updateNetworkIndicator() {
const el = document.getElementById('indicator-network');
if (!el) return;
el.className = 'network-indicator ' + (navigator.onLine ? 'online' : 'offline');
el.setAttribute('aria-label', navigator.onLine ? 'Online' : 'Offline');
}
function _updateUserAvatar() {
const el = document.getElementById('user-avatar');
if (!el) return;
el.textContent = _getInitials(_getUserName());
}
// ========== API calls ==========
2026-03-21 08:13:14 +02:00
async function _apiPost(path, body, extraHeaders) {
2026-03-20 21:09:05 +02:00
const res = await fetch(path, {
method: 'POST',
2026-03-21 08:13:14 +02:00
headers: { 'Content-Type': 'application/json', ...extraHeaders },
2026-03-20 21:09:05 +02:00
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
2026-03-21 08:13:14 +02:00
const err = new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
err.status = res.status;
throw err;
2026-03-20 21:09:05 +02:00
}
return res.json();
}
// ========== Geolocation (optional, non-blocking) ==========
function _getGeo() {
return new Promise((resolve) => {
if (!navigator.geolocation) { resolve(null); return; }
navigator.geolocation.getCurrentPosition(
({ coords }) =>
resolve({
lat: coords.latitude,
lon: coords.longitude,
accuracy: coords.accuracy,
}),
() => resolve(null),
{ timeout: 5000, maximumAge: 30000 }
);
});
}
// ========== Handlers ==========
async function _handleLogin() {
const loginInput = document.getElementById('login-input');
const passInput = document.getElementById('login-password');
const btn = document.getElementById('btn-login');
const login = loginInput.value.trim();
const password = passInput.value;
if (!login || !password) return;
2026-03-20 21:09:05 +02:00
btn.disabled = true;
_setLoginStatus('', '');
2026-03-20 21:09:05 +02:00
try {
const data = await _apiPost('/api/auth/login', {
login_or_email: login,
password: password,
});
_saveAuth(data.token, data.login);
passInput.value = '';
2026-03-20 21:09:05 +02:00
_updateUserAvatar();
_showMain();
} catch (err) {
let msg = 'Ошибка входа. Попробуйте ещё раз.';
if (err && err.message) {
const colonIdx = err.message.indexOf(': ');
if (colonIdx !== -1) {
try {
const parsed = JSON.parse(err.message.slice(colonIdx + 2));
if (parsed.detail) msg = parsed.detail;
} catch (_) {}
}
}
_setLoginStatus(msg, 'error');
2026-03-20 21:09:05 +02:00
btn.disabled = false;
}
}
function _setSosState(state) {
const btn = document.getElementById('btn-sos');
if (!btn) return;
btn.dataset.state = state;
btn.disabled = state === 'sending';
}
async function _handleTestSignal() {
if (!navigator.onLine) {
_setStatus('Нет соединения.', 'error');
return;
}
const token = _getAuthToken();
if (!token) return;
_setStatus('', '');
try {
const geo = await _getGeo();
const body = { timestamp: Date.now(), is_test: true };
if (geo) body.geo = geo;
await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
_setStatus('Тест отправлен', 'success');
setTimeout(() => _setStatus('', ''), 1500);
} catch (err) {
if (err && err.status === 401) {
_clearAuth();
_setStatus('Сессия истекла. Войдите заново.', 'error');
setTimeout(() => _showOnboarding(), 1500);
} else {
_setStatus('Ошибка отправки.', 'error');
}
}
}
2026-03-20 21:09:05 +02:00
async function _handleSignal() {
if (!navigator.onLine) {
_setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error');
return;
}
const token = _getAuthToken();
if (!token) {
_clearAuth();
_showOnboarding();
2026-03-20 21:09:05 +02:00
return;
}
_setSosState('sending');
_setStatus('', '');
try {
const geo = await _getGeo();
const body = { timestamp: Date.now() };
2026-03-20 21:09:05 +02:00
if (geo) body.geo = geo;
await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token });
2026-03-20 21:09:05 +02:00
_setSosState('success');
_setStatus('Сигнал отправлен!', 'success');
2026-03-20 21:09:05 +02:00
setTimeout(() => {
_setSosState('default');
_setStatus('', '');
}, 2000);
2026-03-21 08:13:14 +02:00
} catch (err) {
2026-03-20 21:09:05 +02:00
_setSosState('default');
2026-03-21 08:13:14 +02:00
if (err && err.status === 401) {
_clearAuth();
_setStatus('Сессия истекла. Войдите заново.', 'error');
setTimeout(() => _showOnboarding(), 1500);
2026-03-21 08:13:14 +02:00
} else {
_setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error');
2026-03-21 08:13:14 +02:00
}
2026-03-20 21:09:05 +02:00
}
}
// ========== Screens ==========
function _showOnboarding() {
_showScreen('screen-onboarding');
_showView('view-login');
2026-03-20 21:09:05 +02:00
const loginInput = document.getElementById('login-input');
const passInput = document.getElementById('login-password');
const btnLogin = document.getElementById('btn-login');
2026-03-20 21:09:05 +02:00
function _updateLoginBtn() {
btnLogin.disabled = !loginInput.value.trim() || !passInput.value;
}
loginInput.addEventListener('input', _updateLoginBtn);
passInput.addEventListener('input', _updateLoginBtn);
passInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin();
2026-03-20 21:09:05 +02:00
});
btnLogin.addEventListener('click', _handleLogin);
const btnToRegister = document.getElementById('btn-switch-to-register');
if (btnToRegister) {
btnToRegister.addEventListener('click', () => {
_setRegStatus('', '');
_setLoginStatus('', '');
_showView('view-register');
});
}
const btnToLogin = document.getElementById('btn-switch-to-login');
if (btnToLogin) {
btnToLogin.addEventListener('click', () => {
_setLoginStatus('', '');
_showView('view-login');
});
}
const btnRegister = document.getElementById('btn-register');
if (btnRegister) {
btnRegister.addEventListener('click', _handleSignUp);
}
2026-03-20 21:09:05 +02:00
}
function _showMain() {
_showScreen('screen-main');
_updateUserAvatar();
const btn = document.getElementById('btn-sos');
if (btn && !btn.dataset.listenerAttached) {
btn.addEventListener('click', _handleSignal);
btn.dataset.listenerAttached = '1';
}
// Avatar and network indicator → test signal (only on main screen)
const avatar = document.getElementById('user-avatar');
if (avatar && !avatar.dataset.testAttached) {
avatar.addEventListener('click', _handleTestSignal);
avatar.dataset.testAttached = '1';
avatar.style.cursor = 'pointer';
}
const indicator = document.getElementById('indicator-network');
if (indicator && !indicator.dataset.testAttached) {
indicator.addEventListener('click', _handleTestSignal);
indicator.dataset.testAttached = '1';
indicator.style.cursor = 'pointer';
}
2026-03-20 21:09:05 +02:00
}
// ========== Service Worker ==========
function _registerSW() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('[baton] SW registration failed:', err);
});
}
2026-03-21 11:19:09 +02:00
// ========== VAPID / Push subscription ==========
async function _fetchVapidPublicKey() {
try {
2026-03-21 12:38:52 +02:00
const res = await fetch('/api/push/public-key');
2026-03-21 11:19:09 +02:00
if (!res.ok) {
2026-03-21 12:38:52 +02:00
console.warn('[baton] /api/push/public-key returned', res.status);
2026-03-21 11:19:09 +02:00
return null;
}
const data = await res.json();
return data.vapid_public_key || null;
} catch (err) {
console.warn('[baton] Failed to fetch VAPID public key:', err);
return null;
}
}
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const output = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
output[i] = raw.charCodeAt(i);
}
return output;
}
async function _initPushSubscription(vapidPublicKey) {
if (!vapidPublicKey) {
console.warn('[baton] VAPID public key not available — push subscription skipped');
return;
}
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) return;
const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
_storage.setItem('baton_push_subscription', JSON.stringify(subscription));
console.info('[baton] Push subscription created');
} catch (err) {
console.warn('[baton] Push subscription failed:', err);
}
}
// ========== Registration (account sign-up) ==========
async function _getPushSubscriptionForReg() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
try {
const vapidKey = await _fetchVapidPublicKey();
if (!vapidKey) return null;
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) return existing.toJSON();
const applicationServerKey = _urlBase64ToUint8Array(vapidKey);
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
return subscription.toJSON();
} catch (err) {
console.warn('[baton] Push subscription for registration failed:', err);
return null;
}
}
async function _handleSignUp() {
const emailInput = document.getElementById('reg-email');
const loginInput = document.getElementById('reg-login');
const passwordInput = document.getElementById('reg-password');
const btn = document.getElementById('btn-register');
if (!emailInput || !loginInput || !passwordInput || !btn) return;
const email = emailInput.value.trim();
const login = loginInput.value.trim();
const password = passwordInput.value;
if (!email || !login || !password) {
_setRegStatus('Заполните все поля.', 'error');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
_setRegStatus('Введите корректный email.', 'error');
return;
}
btn.disabled = true;
const originalText = btn.textContent.trim();
btn.textContent = '...';
_setRegStatus('', '');
try {
const push_subscription = await _getPushSubscriptionForReg().catch(() => null);
await _apiPost('/api/auth/register', { email, login, password, push_subscription });
passwordInput.value = '';
_setRegStatus('Заявка отправлена. Ожидайте подтверждения администратора.', 'success');
} catch (err) {
let msg = 'Ошибка. Попробуйте ещё раз.';
if (err && err.message) {
const colonIdx = err.message.indexOf(': ');
if (colonIdx !== -1) {
try {
const parsed = JSON.parse(err.message.slice(colonIdx + 2));
if (parsed.detail) msg = parsed.detail;
} catch (_) {}
}
}
if (err && err.status === 403 && msg !== 'Ошибка. Попробуйте ещё раз.') {
_showBlockScreen(msg);
} else {
_setRegStatus(msg, 'error');
btn.disabled = false;
btn.textContent = originalText;
}
}
}
function _showBlockScreen(msg) {
const screen = document.getElementById('screen-onboarding');
if (!screen) return;
screen.innerHTML =
'<div class="screen-content">' +
'<p class="block-message">' + msg + '</p>' +
'<button type="button" class="btn-confirm" id="btn-block-ok">OK</button>' +
'</div>';
document.getElementById('btn-block-ok').addEventListener('click', () => {
location.reload();
});
}
2026-03-20 21:09:05 +02:00
// ========== Init ==========
function _init() {
_initStorage();
// Private mode graceful degradation (decision #1041)
2026-03-20 21:09:05 +02:00
if (_storageType !== 'local') {
const banner = document.getElementById('private-mode-banner');
if (banner) banner.hidden = false;
}
// Network indicator — initial state + live updates
_updateNetworkIndicator();
window.addEventListener('online', _updateNetworkIndicator);
window.addEventListener('offline', _updateNetworkIndicator);
// Route to correct screen based on JWT token presence
2026-03-20 21:09:05 +02:00
if (_isRegistered()) {
_showMain();
} else {
_showOnboarding();
}
2026-03-21 11:19:09 +02:00
// Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking)
_fetchVapidPublicKey().then(_initPushSubscription).catch((err) => {
console.warn('[baton] Push init error:', err);
});
2026-03-20 21:09:05 +02:00
}
document.addEventListener('DOMContentLoaded', () => {
_registerSW();
_init();
});