Files
Goddess/pwa/index.html
2026-04-12 21:55:26 +02:00

522 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="theme-color" content="#08080a">
<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="NotifyPulse">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="manifest" href="/pwa/manifest.json">
<title>NotifyPulse</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
:root {
--bg: #08080a;
--card: #111115;
--border: #1e1e28;
--accent: #4f8ef7;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--text: #e2e2e8;
--muted: #6b6b7b;
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; background: var(--bg); overflow: hidden; }
body { font-family: 'Space Grotesk', system-ui, sans-serif; color: var(--text); }
/* ── SCREENS ── */
.screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
transition: opacity .45s ease, transform .45s ease;
overflow: hidden;
}
.screen.hidden { opacity: 0; pointer-events: none; transform: scale(.97); }
.screen.gone { display: none; }
/* ── SHARED BACKGROUND (main.png, persists across screens) ── */
#shared-bg {
position: fixed; inset: 0; z-index: 0;
background-size: contain; background-position: center; background-repeat: no-repeat;
transition: opacity 1s ease;
opacity: 0;
}
#shared-bg.loaded { opacity: 1; }
/* Overlay darkening — heavier on selector, lighter on splash */
#shared-bg-overlay {
position: fixed; inset: 0; z-index: 1;
background: rgba(8,8,10,0);
transition: background .6s ease;
}
#shared-bg-overlay.for-splash { background: rgba(8,8,10,.45); }
#shared-bg-overlay.for-selector { background: rgba(8,8,10,.72); backdrop-filter: blur(18px) saturate(.7); -webkit-backdrop-filter: blur(18px) saturate(.7); }
#shared-bg-overlay.for-app { background: rgba(8,8,10,.88); }
/* ── SPLASH ── */
#splash { z-index: 10; background: transparent; }
.splash-content {
position: relative; z-index: 2;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding-bottom: 40px;
animation: fadeUp .8s ease both; animation-delay: .3s;
}
@keyframes fadeUp { from { opacity:0; transform:translateY(18px); } to { opacity:1; transform:translateY(0); } }
.splash-logo {
font-family: 'Space Mono', monospace; font-size: 1.6rem; font-weight: 700;
letter-spacing: .1em; color: #fff;
text-shadow: 0 2px 40px rgba(79,142,247,.7);
}
.splash-sub {
font-size: .72rem; color: rgba(255,255,255,.45);
letter-spacing: .2em; text-transform: uppercase; font-family: 'Space Mono', monospace;
}
.splash-tap {
margin-top: 36px; font-size: .78rem; color: rgba(255,255,255,.3);
letter-spacing: .1em; text-transform: uppercase;
animation: blink 2.2s infinite;
}
@keyframes blink { 0%,100%{opacity:.3} 50%{opacity:.65} }
/* ── SELECTOR ── */
#selector {
z-index: 5; background: transparent;
justify-content: flex-start; align-items: stretch;
}
.sel-header {
position: relative; z-index: 2;
padding: calc(var(--safe-t) + 22px) 20px 16px;
text-align: center; flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.sel-header h1 {
font-family: 'Space Mono', monospace; font-size: 1.05rem; font-weight: 700;
color: rgba(255,255,255,.9); letter-spacing: .1em;
text-shadow: 0 1px 20px rgba(79,142,247,.5);
}
.sel-header p { font-size: .76rem; color: rgba(255,255,255,.4); margin-top: 4px; }
.uc-list {
flex: 1; overflow-y: auto; position: relative; z-index: 2;
padding: 14px 16px calc(var(--safe-b) + 16px);
display: flex; flex-direction: column; gap: 10px;
}
.uc-item {
background: rgba(17,17,21,.75); border: 1px solid rgba(255,255,255,.1);
border-radius: 14px; padding: 15px 17px;
display: flex; align-items: center; gap: 13px;
cursor: pointer; transition: .2s; position: relative; overflow: hidden;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
.uc-item::before {
content:''; position:absolute; left:0; top:0; bottom:0; width:4px;
background: var(--uc-color, var(--accent));
border-radius: 14px 0 0 14px;
}
.uc-item:active { transform: scale(.98); }
.uc-item.active { border-color: rgba(255,255,255,.2); background: rgba(79,142,247,.12); }
.uc-dot {
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
background: var(--uc-color, var(--accent));
opacity: .25; display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
}
.uc-item.active .uc-dot { opacity: .45; }
.uc-info { flex: 1; min-width: 0; }
.uc-name-big { font-size: .92rem; font-weight: 600; color: rgba(255,255,255,.9); }
.uc-blocks-sm { font-size: .63rem; color: rgba(255,255,255,.4); margin-top: 3px; font-family: 'Space Mono', monospace; }
.uc-active-tag {
font-size: .58rem; font-family: 'Space Mono', monospace; color: var(--green);
background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.25);
border-radius: 4px; padding: 2px 6px; text-transform: uppercase; letter-spacing: .08em;
flex-shrink: 0;
}
.uc-arrow { color: rgba(255,255,255,.25); font-size: .9rem; flex-shrink: 0; }
/* ── APP ── */
#app { z-index: 1; background: transparent; justify-content: flex-start; align-items: stretch; }
.app-header {
position: relative; z-index: 2;
padding: calc(var(--safe-t) + 17px) 17px 13px;
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.app-header h1 {
font-family: 'Space Mono', monospace; font-size: .92rem; font-weight: 700;
color: var(--accent); letter-spacing: .06em;
}
.conn-badge {
display: flex; align-items: center; gap: 6px; padding: 5px 12px;
border-radius: 999px; font-size: .7rem; font-weight: 600;
border: 1px solid rgba(255,255,255,.1); background: rgba(17,17,21,.6); color: var(--muted);
transition: .3s;
}
.conn-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.conn-badge.ok { border-color: rgba(34,197,94,.35); color: var(--green); }
.conn-badge.ok .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.conn-badge.err { border-color: rgba(239,68,68,.35); color: var(--red); }
.conn-badge.err .dot { background: var(--red); }
.app-body {
flex: 1; overflow-y: auto; position: relative; z-index: 2;
padding: 13px 15px calc(var(--safe-b) + 18px);
display: flex; flex-direction: column; gap: 11px;
}
/* Stat row */
.stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat { background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 11px 13px; backdrop-filter: blur(4px); }
.sl { font-size: 9px; color: var(--muted); font-family: 'Space Mono', monospace; text-transform: uppercase; letter-spacing: .07em; margin-bottom: 3px; }
.sv { font-size: .92rem; font-weight: 700; }
.sv.green { color: var(--green); }
.sv.yellow { color: var(--yellow); }
.sv.accent { color: var(--accent); }
/* Cards */
.c { background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.08); border-radius: 13px; padding: 14px; backdrop-filter: blur(4px); }
.ct { font-size: .6rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); font-family: 'Space Mono', monospace; margin-bottom: 11px; }
/* Wallpaper section */
.wp-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
.wp-tab {
flex: 1; padding: 8px; border-radius: 8px;
border: 1px solid rgba(255,255,255,.1); background: transparent;
color: var(--muted); font-size: .74rem; font-weight: 600;
cursor: pointer; font-family: inherit; transition: .15s;
}
.wp-tab.active { border-color: var(--accent); color: var(--accent); background: rgba(79,142,247,.1); }
.wp-frame {
border-radius: 10px; overflow: hidden; background: rgba(0,0,0,.4);
border: 1px solid rgba(255,255,255,.08); aspect-ratio: 9/16;
max-height: 250px; display: flex; align-items: center; justify-content: center; position: relative;
}
.wp-frame img { width: 100%; height: 100%; object-fit: cover; transition: opacity .4s; }
.wp-ph { display: flex; flex-direction: column; align-items: center; gap: 7px; color: var(--muted); font-size: .72rem; }
/* Buttons */
.btn {
width: 100%; padding: 12px; border: none; border-radius: 10px; cursor: pointer;
font-family: inherit; font-size: .83rem; font-weight: 600; transition: .15s;
display: flex; align-items: center; justify-content: center; gap: 7px;
}
.btn-accent { background: var(--accent); color: #fff; }
.btn-accent:active { filter: brightness(.9); }
.btn-ghost { background: rgba(255,255,255,.07); color: var(--text); border: 1px solid rgba(255,255,255,.1); }
.btn-ghost:active { background: rgba(255,255,255,.12); }
.btn-green { background: rgba(34,197,94,.1); color: var(--green); border: 1px solid rgba(34,197,94,.2); }
.btn-green:active { background: rgba(34,197,94,.2); }
/* Usecase chip at top of app */
.uc-chip {
display: flex; align-items: center; gap: 8px;
background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.1);
border-radius: 10px; padding: 10px 14px; cursor: pointer;
backdrop-filter: blur(4px);
}
.uc-chip-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.uc-chip-name { font-size: .85rem; font-weight: 600; flex: 1; }
.uc-chip-change { font-size: .7rem; color: var(--muted); }
.save-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* Toast */
.toast {
position: fixed; bottom: calc(var(--safe-b) + 80px); left: 50%; transform: translateX(-50%);
background: var(--green); color: #000; padding: 9px 20px; border-radius: 999px;
font-size: .8rem; font-weight: 700; white-space: nowrap;
pointer-events: none; opacity: 0; transition: .3s; z-index: 99;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<!-- Persistent blurred background image (behind all screens) -->
<div id="shared-bg"></div>
<div id="shared-bg-overlay" class="for-splash"></div>
<!-- SPLASH -->
<div class="screen" id="splash" onclick="goToSelector()">
<div class="splash-content">
<div class="splash-logo" id="splashName">NOTIFYPULSE</div>
<div class="splash-sub" id="splashSub">V3</div>
<div class="splash-tap">Tap to continue</div>
</div>
</div>
<!-- SELECTOR -->
<div class="screen hidden gone" id="selector">
<div class="sel-header">
<h1 id="selHeaderName">NOTIFYPULSE</h1>
<p>Choose a usecase</p>
</div>
<div class="uc-list" id="ucList">Loading…</div>
</div>
<!-- APP -->
<div class="screen hidden gone" id="app">
<div class="app-header">
<h1 id="appNameEl">NOTIFYPULSE</h1>
<div class="conn-badge" id="connBadge">
<div class="dot"></div><span id="connText">Connecting</span>
</div>
</div>
<div class="app-body">
<div class="uc-chip" onclick="goToSelector()">
<div class="uc-chip-dot" id="ucChipDot" style="background:var(--accent)"></div>
<span class="uc-chip-name" id="ucChipName"></span>
<span class="uc-chip-change">Switch ↗</span>
</div>
<div class="stat-row">
<div class="stat"><div class="sl">Status</div><div class="sv" id="a-status"></div></div>
<div class="stat"><div class="sl">Next Fire</div><div class="sv accent" id="a-next"></div></div>
</div>
<div class="c" id="wpCard">
<div class="ct">Mobile Wallpaper</div>
<div class="wp-tabs">
<button class="wp-tab active" onclick="wpTab('lockscreen',this)">Lock Screen</button>
<button class="wp-tab" onclick="wpTab('background',this)">Home Screen</button>
</div>
<div class="wp-frame">
<img id="wpImg" src="" style="opacity:0">
<div class="wp-ph" id="wpPh">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<span>No wallpaper yet</span>
</div>
</div>
<div style="height:8px"></div>
<div class="save-row">
<button class="btn btn-ghost" onclick="saveWp('lockscreen')">💾 Save Lock</button>
<button class="btn btn-ghost" onclick="saveWp('background')">💾 Save Home</button>
</div>
<div style="height:8px"></div>
<button class="btn btn-green" onclick="requestWallpaper()">🔄 Request New Wallpaper</button>
</div>
<button class="btn btn-ghost" onclick="goToSelector()">↩ Back to Usecases</button>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const CLIENT_ID = 'pwa_' + Math.random().toString(36).slice(2,11);
let appInfo = null, activeUCId = '', allBlocks = [];
let wpImages = {lockscreen:null, background:null}, wpCurTab = 'lockscreen';
let pingInterval, wallpaperInterval, statInterval;
let bgImageLoaded = false;
// ── Screen transitions ─────────────────────────────────────
function show(id) {
// Update background overlay style
const overlay = document.getElementById('shared-bg-overlay');
overlay.className = 'for-' + (id === 'splash' ? 'splash' : id === 'selector' ? 'selector' : 'app');
document.querySelectorAll('.screen').forEach(s => {
s.classList.remove('gone');
s.classList.toggle('hidden', s.id !== id);
});
setTimeout(() => {
document.querySelectorAll('.screen.hidden').forEach(s => s.classList.add('gone'));
}, 500);
}
function goToSelector() {
show('selector');
loadSelectorList();
}
function goToApp(ucId) {
show('app');
setActiveUC(ucId);
}
// ── Init ──────────────────────────────────────────────────
async function init() {
// Load splash image and keep it as shared background
const splashR = await fetchJSON('/api/pwa/splash_image').catch(()=>null);
if (splashR && splashR.image) {
const bg = document.getElementById('shared-bg');
bg.style.backgroundImage = `url('${splashR.image}')`;
requestAnimationFrame(() => { bg.classList.add('loaded'); bgImageLoaded = true; });
}
// Load app info
const info = await fetchJSON('/api/pwa/app_name').catch(()=>null);
if (info) {
appInfo = info;
activeUCId = info.active || '';
allBlocks = info.blocks || [];
const settingsName = info.settings_app_name || info.app_name || 'NOTIFYPULSE';
document.getElementById('splashName').textContent = settingsName.toUpperCase();
document.getElementById('selHeaderName').textContent = settingsName.toUpperCase();
document.getElementById('splashSub').textContent = `${(info.usecases||[]).length} USECASE(S)`;
}
startPing();
}
// ── Selector list ─────────────────────────────────────────
async function loadSelectorList() {
const r = await fetchJSON('/api/pwa/app_name').catch(()=>null);
if (!r) { document.getElementById('ucList').innerHTML = '<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">Connection error</p>'; return; }
const settingsName = r.settings_app_name || r.app_name || 'NOTIFYPULSE';
document.getElementById('selHeaderName').textContent = settingsName.toUpperCase();
activeUCId = r.active || '';
allBlocks = r.blocks || [];
const usecases = r.usecases || [];
document.getElementById('ucList').innerHTML = usecases.length ? usecases.map(uc => {
const isActive = uc.id === activeUCId;
const blockIcons = (uc.blocks||[]).map(bid => {
const b = allBlocks.find(x=>x.id===bid);
return b ? b.icon : '';
}).join(' ');
return `<div class="uc-item ${isActive?'active':''}" style="--uc-color:${uc.color}" onclick="pickUC('${uc.id}','${uc.color}')">
<div class="uc-dot" style="background:${uc.color}">${uc.name[0]}</div>
<div class="uc-info">
<div class="uc-name-big">${escHtml(uc.name)}</div>
<div class="uc-blocks-sm">${blockIcons}</div>
</div>
${isActive ? '<span class="uc-active-tag">Active</span>' : '<span class="uc-arrow"></span>'}
</div>`;
}).join('') : '<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">No usecases yet.<br>Create them in the desktop UI.</p>';
}
async function pickUC(id, color) {
// Activate on server
await postJSON('/api/pwa/activate_usecase', {usecase_id: id}).catch(()=>null);
activeUCId = id;
goToApp(id);
}
// ── App view ──────────────────────────────────────────────
async function setActiveUC(ucId) {
const r = await fetchJSON('/api/pwa/app_name').catch(()=>null);
if (!r) return;
const uc = (r.usecases||[]).find(u=>u.id===ucId);
if (!uc) return;
document.getElementById('appNameEl').textContent = uc.name.toUpperCase();
document.getElementById('ucChipName').textContent = uc.name;
document.getElementById('ucChipDot').style.background = uc.color;
const hasMobile = (uc.blocks||[]).includes('mobile_wallpaper');
document.getElementById('wpCard').style.display = hasMobile ? 'block' : 'none';
clearInterval(statInterval);
statInterval = setInterval(updateStats, 3000);
updateStats();
clearInterval(wallpaperInterval);
if (hasMobile) wallpaperInterval = setInterval(pollWallpaper, 4000);
}
async function updateStats() {
const s = await fetchJSON('/api/state').catch(()=>null);
if (!s) return;
const paused = s.paused;
document.getElementById('a-status').textContent = paused ? '⏸ Paused' : '▶ Running';
document.getElementById('a-status').className = 'sv ' + (paused ? 'yellow' : 'green');
const next = s.next_fire_at ? Math.max(0, Math.round(s.next_fire_at - Date.now()/1000)) : 0;
document.getElementById('a-next').textContent = next > 0 ? `${Math.floor(next/60)}m ${next%60}s` : '—';
}
// ── Wallpaper ─────────────────────────────────────────────
async function pollWallpaper() {
const r = await fetchJSON(`/api/pwa/wallpaper?client_id=${CLIENT_ID}`).catch(()=>null);
if (!r || !r.pending) return;
if (r.lockscreen) wpImages.lockscreen = r.lockscreen;
if (r.background) wpImages.background = r.background;
displayWp(wpCurTab);
showToast('New wallpaper received!');
}
async function requestWallpaper() {
await postJSON('/api/pwa/trigger_wallpaper', {});
showToast('Request sent…');
}
function wpTab(tab, btn) {
wpCurTab = tab;
document.querySelectorAll('.wp-tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
displayWp(tab);
}
function displayWp(tab) {
const img = document.getElementById('wpImg');
const ph = document.getElementById('wpPh');
const src = wpImages[tab];
if (src) { img.src=src; img.style.opacity='1'; ph.style.display='none'; }
else { img.style.opacity='0'; ph.style.display='flex'; }
}
function saveWp(tab) {
const src = wpImages[tab];
if (!src) { showToast('No image to save'); return; }
const a = document.createElement('a');
a.href = src; a.download = tab==='lockscreen'?'Lockscreen.jpg':'Background.jpg'; a.click();
}
// ── Ping ──────────────────────────────────────────────────
function startPing() {
clearInterval(pingInterval);
pingInterval = setInterval(doPing, 4000);
doPing();
}
async function doPing() {
try {
await postJSON('/api/pwa/ping', {client_id: CLIENT_ID});
document.getElementById('connBadge').className = 'conn-badge ok';
document.getElementById('connText').textContent = 'Connected';
} catch {
document.getElementById('connBadge').className = 'conn-badge err';
document.getElementById('connText').textContent = 'Offline';
}
}
// ── Helpers ───────────────────────────────────────────────
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return r.json();
}
async function postJSON(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}).then(r=>r.json());
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(()=>t.classList.remove('show'), 2500);
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Countdown ticker
setInterval(()=>{
const el = document.getElementById('a-next');
if (!el) return;
const m = el.textContent.match(/(\d+)m (\d+)s/);
if (!m) return;
let t = parseInt(m[1])*60 + parseInt(m[2]) - 1;
if (t < 0) t = 0;
el.textContent = `${Math.floor(t/60)}m ${t%60}s`;
}, 1000);
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/pwa/sw.js');
init();
</script>
</body>
</html>