diff --git a/frontend/public/css/styles.css b/frontend/public/css/styles.css index 5356d56..ffafd42 100644 --- a/frontend/public/css/styles.css +++ b/frontend/public/css/styles.css @@ -514,6 +514,62 @@ body[data-user="cer"] .brand__user[data-brand="cer"] { color: var(--accent-cer); text-transform: uppercase; } +/* ---- Install button ---- */ + +.install-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--gap-2); +} + +.install-btn { + background: transparent; + border: var(--hairline) solid var(--line); + color: var(--fg-mute); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + padding: 8px 18px; + cursor: pointer; + border-radius: var(--radius); +} +.install-btn:hover { border-color: var(--fg-mute); color: var(--fg); } + +.install-hint { + font-family: var(--font-mono); + font-size: 10px; + color: var(--fg-mute); + letter-spacing: 0.10em; + text-transform: uppercase; + margin: 0; + text-align: center; +} + +/* ---- Active user pill in topbar ---- */ + +body[data-user="ray"] .brand--sm .brand__user[data-brand="ray"], +body[data-user="cer"] .brand--sm .brand__user[data-brand="cer"] { + background: var(--accent); + color: var(--bg); + padding: 2px 6px; + border-radius: var(--radius); + border-bottom-color: transparent; +} + +/* ---- Per-user site frame ---- */ + +body[data-user="ray"]::after, +body[data-user="cer"]::after { + content: ''; + position: fixed; + inset: 0; + border: 1px solid var(--accent); + pointer-events: none; + z-index: 9999; +} + /* ---- Misc ---- */ .sr-only { diff --git a/frontend/public/index.html b/frontend/public/index.html index 1a0b88c..31ea106 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -29,6 +29,10 @@ cer

choose to continue

+ diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js index 5a9fd62..3fc4986 100644 --- a/frontend/public/js/app.js +++ b/frontend/public/js/app.js @@ -4,6 +4,8 @@ import { startSync, setCompletion, onChange, + getMeta, + setMeta, } from './db.js'; import { initTheme, cycleTheme, currentTheme } from './theme.js'; import { renderCalendar } from './calendar.js'; @@ -19,6 +21,27 @@ import { const VALID_USERS = new Set(['ray', 'cer']); const app = document.getElementById('app'); +/* ---------- PWA install prompt ---------- */ + +let deferredInstallPrompt = null; +const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent); +const isStandalone = + window.matchMedia('(display-mode: standalone)').matches || + ('standalone' in navigator && navigator.standalone === true); + +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferredInstallPrompt = e; + const btn = document.querySelector('[data-install-wrap]'); + if (btn) btn.hidden = false; +}); + +window.addEventListener('appinstalled', () => { + deferredInstallPrompt = null; + const wrap = document.querySelector('[data-install-wrap]'); + if (wrap) wrap.hidden = true; +}); + const state = { route: '/', user: '', @@ -48,6 +71,7 @@ function handleRoute() { state.user = user; document.body.dataset.route = route; document.body.dataset.user = user || ''; + if (user) setMeta('last_route', route).catch(() => {}); render(); } @@ -85,6 +109,29 @@ function tpl(id) { function renderLanding() { app.innerHTML = ''; app.appendChild(tpl('tpl-landing')); + + if (isStandalone) return; + + const wrap = app.querySelector('[data-install-wrap]'); + const btn = app.querySelector('[data-install-btn]'); + const hint = app.querySelector('[data-install-hint]'); + + if (deferredInstallPrompt) { + wrap.hidden = false; + btn.addEventListener('click', async () => { + deferredInstallPrompt.prompt(); + const { outcome } = await deferredInstallPrompt.userChoice; + if (outcome === 'accepted') { + deferredInstallPrompt = null; + wrap.hidden = true; + } + }); + } else if (isIOS) { + wrap.hidden = false; + btn.addEventListener('click', () => { + hint.hidden = !hint.hidden; + }); + } } /* ---------- Render: route page ---------- */ @@ -343,6 +390,16 @@ function render() { async function boot() { await initTheme(); await loadFromDexie(); + + // Restore last visited user route if opening at root + const curPath = location.pathname; + if (curPath === '/' || curPath === '') { + const saved = await getMeta('last_route', null); + if (saved && VALID_USERS.has(saved.slice(1))) { + history.replaceState({}, '', saved); + } + } + handleRoute(); // Hydrate from server (non-blocking) and start sync