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