Merge branch 'BATON-ARCH-009-frontend_dev'
This commit is contained in:
commit
bfa134e157
7 changed files with 625 additions and 0 deletions
264
frontend/app.js
Normal file
264
frontend/app.js
Normal file
|
|
@ -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();
|
||||
});
|
||||
BIN
frontend/icons/icon-192.png
Normal file
BIN
frontend/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 B |
BIN
frontend/icons/icon-512.png
Normal file
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
78
frontend/index.html
Normal file
78
frontend/index.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="description" content="Emergency signal button">
|
||||
|
||||
<!-- PWA meta tags -->
|
||||
<meta name="theme-color" content="#ff0000">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Baton">
|
||||
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
|
||||
<title>Baton</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Private mode banner: shown when localStorage is unavailable (decision #1041).
|
||||
Explicit action guidance: user must open in normal mode to retain registration. -->
|
||||
<div id="private-mode-banner" class="private-banner" hidden>
|
||||
⚠️ Private mode detected. Your registration will be lost when this tab
|
||||
closes. To keep your data, open Baton in a regular (non-private) browser tab.
|
||||
</div>
|
||||
|
||||
<!-- Shared top bar: user initials avatar + network status indicator -->
|
||||
<header class="topbar">
|
||||
<div class="user-avatar" id="user-avatar" aria-label="User">?</div>
|
||||
<div class="network-indicator" id="indicator-network"
|
||||
role="status" aria-label="Network status"></div>
|
||||
</header>
|
||||
|
||||
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
|
||||
<div id="screen-onboarding" class="screen" role="main" hidden>
|
||||
<div class="screen-content">
|
||||
<input
|
||||
type="text"
|
||||
id="name-input"
|
||||
class="name-input"
|
||||
placeholder="Your name"
|
||||
maxlength="100"
|
||||
autocomplete="name"
|
||||
autocorrect="off"
|
||||
autocapitalize="words"
|
||||
spellcheck="false"
|
||||
aria-label="Your name"
|
||||
>
|
||||
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main screen: SOS button -->
|
||||
<div id="screen-main" class="screen" role="main" hidden>
|
||||
<div class="screen-content">
|
||||
<button
|
||||
type="button"
|
||||
id="btn-sos"
|
||||
class="btn-sos"
|
||||
data-state="default"
|
||||
aria-label="Send emergency signal"
|
||||
>
|
||||
SOS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status message: errors and confirmations -->
|
||||
<div id="status" class="status" role="alert" aria-live="polite" hidden></div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/manifest.json
Normal file
23
frontend/manifest.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "Baton",
|
||||
"short_name": "Baton",
|
||||
"description": "Emergency signal button",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"start_url": "/",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#ff0000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
200
frontend/style.css
Normal file
200
frontend/style.css
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--text: #ffffff;
|
||||
--muted: #9ca3af;
|
||||
--input-bg: #1a1a1a;
|
||||
--border: #374151;
|
||||
--border-focus: #6b7280;
|
||||
--confirm-bg: #374151;
|
||||
--confirm-active: #4b5563;
|
||||
--sos: #cc2626;
|
||||
--sos-pressed: #991c1c;
|
||||
--sos-success: #16a34a;
|
||||
--banner-bg: #7c2d12;
|
||||
--banner-text: #fed7aa;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* Use dynamic viewport height on mobile to account for browser chrome */
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ===== Private mode banner (decision #1041) ===== */
|
||||
|
||||
.private-banner {
|
||||
background: var(--banner-bg);
|
||||
color: var(--banner-text);
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.private-banner[hidden] { display: none; }
|
||||
|
||||
/* ===== Top bar ===== */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.network-indicator {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.network-indicator.online { background: #16a34a; }
|
||||
.network-indicator.offline { background: #4b5563; }
|
||||
|
||||
/* ===== Screens ===== */
|
||||
|
||||
.screen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.screen[hidden] { display: none; }
|
||||
|
||||
.screen-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
/* ===== Onboarding ===== */
|
||||
|
||||
.name-input {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 14px 16px;
|
||||
background: var(--input-bg);
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
-webkit-appearance: none;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.name-input::placeholder { color: var(--muted); }
|
||||
.name-input:focus { border-color: var(--border-focus); }
|
||||
|
||||
.btn-confirm {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 14px;
|
||||
background: var(--confirm-bg);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-confirm:disabled { opacity: 0.35; cursor: default; }
|
||||
.btn-confirm:not(:disabled):active { background: var(--confirm-active); }
|
||||
|
||||
/* ===== SOS button (min 60vmin × 60vmin per UX spec) ===== */
|
||||
|
||||
.btn-sos {
|
||||
width: 60vmin;
|
||||
height: 60vmin;
|
||||
min-width: 180px;
|
||||
min-height: 180px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--sos);
|
||||
color: #fff;
|
||||
font-size: clamp(24px, 9vmin, 60px);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.btn-sos:active,
|
||||
.btn-sos[data-state="sending"] {
|
||||
background: var(--sos-pressed);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.btn-sos[data-state="sending"] {
|
||||
animation: baton-pulse 1s ease-in-out infinite;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-sos[data-state="success"] {
|
||||
background: var(--sos-success);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@keyframes baton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
/* ===== Status message ===== */
|
||||
|
||||
.status {
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status[hidden] { display: none; }
|
||||
.status--error { color: #f87171; }
|
||||
.status--success { color: #4ade80; }
|
||||
60
frontend/sw.js
Normal file
60
frontend/sw.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'baton-v1';
|
||||
|
||||
// App shell assets to precache
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/style.css',
|
||||
'/manifest.json',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
];
|
||||
|
||||
// Install: precache app shell + skipWaiting so new SW activates immediately
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||
);
|
||||
});
|
||||
|
||||
// Activate: delete stale caches + claim open clients (decision: skipWaiting + clients.claim)
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch: cache-first for app shell; API calls pass through to network
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Never intercept API calls — they must always reach the server
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
|
||||
return fetch(event.request).then((response) => {
|
||||
if (response && response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue