diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..10c4b1b --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,264 @@ +'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 _getOrCreateUserId() { + let id = _storage.getItem('baton_user_id'); + if (!id) { + id = crypto.randomUUID(); + _storage.setItem('baton_user_id', id); + } + return id; +} + +function _isRegistered() { + return _storage.getItem('baton_registered') === '1'; +} + +function _getUserName() { + return _storage.getItem('baton_user_name') || ''; +} + +function _saveRegistration(name) { + _storage.setItem('baton_user_name', name); + _storage.setItem('baton_registered', '1'); +} + +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 _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 ========== + +async function _apiPost(path, body) { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error('HTTP ' + res.status + (text ? ': ' + text : '')); + } + 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 _handleRegister() { + const input = document.getElementById('name-input'); + const btn = document.getElementById('btn-confirm'); + const name = input.value.trim(); + if (!name) return; + + btn.disabled = true; + _setStatus('', ''); + + try { + const uuid = _getOrCreateUserId(); + await _apiPost('/api/register', { uuid, name }); + _saveRegistration(name); + _updateUserAvatar(); + _showMain(); + } catch (_) { + _setStatus('Error. Please try again.', 'error'); + 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 _handleSignal() { + // v1: no offline queue — show error and return (decision #1019) + if (!navigator.onLine) { + _setStatus('No connection. Check your network and try again.', 'error'); + return; + } + + _setSosState('sending'); + _setStatus('', ''); + + try { + const geo = await _getGeo(); + const uuid = _getOrCreateUserId(); + const body = { user_id: uuid, timestamp: Date.now() }; + if (geo) body.geo = geo; + + await _apiPost('/api/signal', body); + + _setSosState('success'); + _setStatus('Signal sent!', 'success'); + setTimeout(() => { + _setSosState('default'); + _setStatus('', ''); + }, 2000); + } catch (_) { + _setSosState('default'); + _setStatus('Error sending. Try again.', 'error'); + } +} + +// ========== Screens ========== + +function _showOnboarding() { + _showScreen('screen-onboarding'); + + const input = document.getElementById('name-input'); + const btn = document.getElementById('btn-confirm'); + + input.addEventListener('input', () => { + btn.disabled = input.value.trim().length === 0; + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !btn.disabled) _handleRegister(); + }); + btn.addEventListener('click', _handleRegister); +} + +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'; + } +} + +// ========== Service Worker ========== + +function _registerSW() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('/sw.js').catch((err) => { + console.warn('[baton] SW registration failed:', err); + }); +} + +// ========== Init ========== + +function _init() { + _initStorage(); + + // Pre-generate and persist UUID on first visit (per arch spec flow) + _getOrCreateUserId(); + + // Private mode graceful degradation (decision #1041): + // show inline banner with explicit action guidance when localStorage is unavailable + 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 + if (_isRegistered()) { + _showMain(); + } else { + _showOnboarding(); + } +} + +document.addEventListener('DOMContentLoaded', () => { + _registerSW(); + _init(); +}); diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000..0ddef34 Binary files /dev/null and b/frontend/icons/icon-192.png differ diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000..7c2d680 Binary files /dev/null and b/frontend/icons/icon-512.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e5fe30e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,78 @@ + + +
+ + + + + + + + + + + + + + + +