Files
Goddess/pwa/index.html
TutorialsGHG fca54607cb 3.3
2026-04-14 21:39:07 +02:00

1395 lines
36 KiB
HTML
Raw Permalink 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;
--mono: 'Space Mono', monospace;
--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 0.45s ease,
transform 0.45s ease;
overflow: hidden;
}
.screen.hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.97);
}
.screen.gone {
display: none;
}
/* ── SHARED BACKGROUND ── */
#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;
}
#shared-bg-overlay {
position: fixed;
inset: 0;
z-index: 1;
background: rgba(8, 8, 10, 0);
transition: background 0.6s ease;
}
#shared-bg-overlay.for-splash {
background: rgba(8, 8, 10, 0.45);
}
#shared-bg-overlay.for-selector {
background: rgba(8, 8, 10, 0.72);
backdrop-filter: blur(18px) saturate(0.7);
-webkit-backdrop-filter: blur(18px) saturate(0.7);
}
#shared-bg-overlay.for-app {
background: rgba(8, 8, 10, 0.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 0.8s ease both;
animation-delay: 0.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: 0.1em;
color: #fff;
text-shadow: 0 2px 40px rgba(79, 142, 247, 0.7);
}
.splash-sub {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.2em;
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.splash-tap {
margin-top: 36px;
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.3);
letter-spacing: 0.1em;
text-transform: uppercase;
animation: blink 2.2s infinite;
}
@keyframes blink {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.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 14px;
text-align: center;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sel-header h1 {
font-family: 'Space Mono', monospace;
font-size: 1.05rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 0.1em;
text-shadow: 0 1px 20px rgba(79, 142, 247, 0.5);
}
.sel-header p {
font-size: 0.76rem;
color: rgba(255, 255, 255, 0.4);
margin-top: 4px;
}
/* ── TREE VIEW ── */
.uc-list {
flex: 1;
overflow-y: auto;
position: relative;
z-index: 2;
padding: 12px 14px calc(var(--safe-b) + 16px);
display: flex;
flex-direction: column;
gap: 2px;
}
.tree-root {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Folder row */
.tree-folder {
display: flex;
flex-direction: column;
}
.tree-folder-hdr {
display: flex;
align-items: center;
gap: 0;
padding: 8px 10px 8px 6px;
cursor: pointer;
border-radius: 10px;
user-select: none;
transition: background 0.12s;
background: rgba(17, 17, 21, 0.5);
border: 1px solid rgba(255, 255, 255, 0.07);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
margin-bottom: 2px;
}
.tree-folder-hdr:active {
background: rgba(255, 255, 255, 0.07);
}
/* Indent guides */
.tree-indent {
display: flex;
flex-shrink: 0;
}
.tree-guide {
width: 20px;
flex-shrink: 0;
position: relative;
display: flex;
justify-content: center;
}
.tree-guide::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(255, 255, 255, 0.1);
}
.tree-guide.last::before {
bottom: 50%;
}
/* Arrow + icon */
.tree-arrow {
width: 18px;
height: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: var(--muted);
transition: transform 0.18s;
}
.tree-arrow.open {
transform: rotate(90deg);
}
.tree-folder-icon {
font-size: 0.95rem;
margin-right: 8px;
flex-shrink: 0;
}
.tree-folder-label {
font-size: 0.85rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
flex: 1;
}
.tree-folder-count {
font-size: 0.6rem;
color: var(--muted);
font-family: var(--mono);
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 999px;
padding: 1px 7px;
margin-left: 8px;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 10px;
margin-bottom: 4px;
}
.tree-children.collapsed {
display: none;
}
/* Leaf = usecase item */
.tree-leaf {
display: flex;
align-items: center;
gap: 0;
cursor: pointer;
border-radius: 10px;
transition: background 0.12s;
background: rgba(17, 17, 21, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
overflow: hidden;
position: relative;
}
.tree-leaf::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--uc-color, var(--accent));
}
.tree-leaf:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.07);
}
.tree-leaf.active-uc {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(79, 142, 247, 0.1);
}
.tree-leaf-inner {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px 10px 10px;
min-width: 0;
}
.tree-leaf-color {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
background: var(--uc-color, var(--accent));
box-shadow: 0 0 6px var(--uc-color, var(--accent));
}
.tree-leaf-name {
font-size: 0.88rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-leaf-chips {
display: flex;
flex-wrap: wrap;
gap: 3px;
flex-shrink: 0;
}
.tree-leaf-chip {
padding: 1px 6px;
border-radius: 4px;
font-size: 0.6rem;
font-family: var(--mono);
font-weight: 600;
background: rgba(79, 142, 247, 0.1);
color: var(--accent);
border: 1px solid rgba(79, 142, 247, 0.2);
}
.tree-leaf-active-tag {
font-size: 0.58rem;
font-family: 'Space Mono', monospace;
color: var(--green);
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(34, 197, 94, 0.25);
border-radius: 4px;
padding: 2px 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
flex-shrink: 0;
}
.tree-leaf-arrow {
color: rgba(255, 255, 255, 0.2);
font-size: 0.85rem;
flex-shrink: 0;
padding-right: 2px;
}
/* ── 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, 0.08);
}
.app-header h1 {
font-family: 'Space Mono', monospace;
font-size: 0.92rem;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
}
.conn-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(17, 17, 21, 0.6);
color: var(--muted);
transition: 0.3s;
}
.conn-badge .dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
}
.conn-badge.ok {
border-color: rgba(34, 197, 94, 0.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, 0.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, 0.7);
border: 1px solid rgba(255, 255, 255, 0.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: 0.07em;
margin-bottom: 3px;
}
.sv {
font-size: 0.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, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 13px;
padding: 14px;
backdrop-filter: blur(4px);
}
.ct {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.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, 0.1);
background: transparent;
color: var(--muted);
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: 0.15s;
}
.wp-tab.active {
border-color: var(--accent);
color: var(--accent);
background: rgba(79, 142, 247, 0.1);
}
.wp-frame {
border-radius: 10px;
overflow: hidden;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.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 0.4s;
}
.wp-ph {
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 0.72rem;
}
/* Buttons */
.btn {
width: 100%;
padding: 12px;
border: none;
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.83rem;
font-weight: 600;
transition: 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.btn-accent {
background: var(--accent);
color: #fff;
}
.btn-accent:active { filter: brightness(0.9); }
.btn-ghost {
background: rgba(255, 255, 255, 0.07);
color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-ghost:active { background: rgba(255, 255, 255, 0.12); }
.btn-green {
background: rgba(34, 197, 94, 0.1);
color: var(--green);
border: 1px solid rgba(34, 197, 94, 0.2);
}
.btn-green:active { background: rgba(34, 197, 94, 0.2); }
/* Usecase chip at top of app */
.uc-chip {
display: flex;
align-items: center;
gap: 8px;
background: rgba(17, 17, 21, 0.7);
border: 1px solid rgba(255, 255, 255, 0.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: 0.85rem;
font-weight: 600;
flex: 1;
}
.uc-chip-change {
font-size: 0.7rem;
color: var(--muted);
}
.save-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
/* ── PWA OVERLAY ── */
#pwa-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: #000;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s ease;
}
#pwa-overlay.visible {
opacity: 1;
pointer-events: all;
}
#pwa-overlay img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* ── BG SETTINGS PANEL ── */
#bg-settings-btn {
position: fixed;
bottom: calc(var(--safe-b) + 18px);
right: 18px;
z-index: 20;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(17, 17, 21, 0.7);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.4);
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
transition: 0.2s;
}
#bg-settings-btn:hover {
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.3);
}
#bg-settings-panel {
position: fixed;
bottom: calc(var(--safe-b) + 62px);
right: 14px;
z-index: 20;
width: 240px;
background: rgba(17, 17, 21, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 14px;
padding: 16px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
#bg-settings-panel.open {
opacity: 1;
pointer-events: all;
transform: translateY(0);
}
.bgs-title {
font-size: 0.65rem;
font-family: 'Space Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin-bottom: 14px;
}
.bgs-row {
margin-bottom: 12px;
}
.bgs-label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.74rem;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
.bgs-label span {
font-family: 'Space Mono', monospace;
color: var(--accent);
font-size: 0.7rem;
}
.bgs-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.15);
outline: none;
cursor: pointer;
}
.bgs-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 6px rgba(79, 142, 247, 0.5);
}
.bgs-save {
margin-top: 14px;
}
/* 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: 0.8rem;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: 0.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>
<!-- BG Settings button (always visible) -->
<button
id="bg-settings-btn"
onclick="toggleBgSettings()"
title="Background settings"
>
</button>
<div id="bg-settings-panel">
<div class="bgs-title">Background Appearance</div>
<div class="bgs-row">
<div class="bgs-label">Blur <span id="blurVal">18px</span></div>
<input
class="bgs-slider"
id="blurSlider"
type="range"
min="0"
max="40"
step="1"
value="18"
oninput="previewBg()"
/>
</div>
<div class="bgs-row">
<div class="bgs-label">Darkness <span id="opacVal">72%</span></div>
<input
class="bgs-slider"
id="opacSlider"
type="range"
min="0"
max="95"
step="1"
value="72"
oninput="previewBg()"
/>
</div>
<button class="btn btn-accent bgs-save" onclick="saveBgSettings()">
Save
</button>
</div>
<!-- PWA Overlay — mirrors the desktop screen overlay -->
<div id="pwa-overlay"><img id="pwa-overlay-img" src="" alt="" /></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) {
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() {
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;
});
}
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)`;
applyBgSettings(
info.pwa_bg_blur !== undefined ? info.pwa_bg_blur : 18,
info.pwa_bg_opacity !== undefined ? info.pwa_bg_opacity : 0.72,
);
}
startPing();
startOverlayPoll();
}
// ── Tree builder ──────────────────────────────────────────
const SEP = /\s*(?:\/|>|\u203a)\s*/;
let _treeGid = 0;
function insertIntoTree(node, parts, uc) {
if (!parts.length) {
node._items.push(uc);
return;
}
const k = parts[0];
if (!node[k]) node[k] = { _items: [] };
insertIntoTree(node[k], parts.slice(1), uc);
}
function countLeaves(node) {
let n = (node._items || []).length;
Object.keys(node)
.filter((k) => k !== '_items')
.forEach((k) => (n += countLeaves(node[k])));
return n;
}
function leafHtml(uc, depth) {
const indent = Array.from(
{ length: depth },
() => '<span class="tree-guide"></span>',
).join('');
const chips = (uc.blocks || [])
.slice(0, 3)
.map((bid) => {
const b = allBlocks.find((x) => x.id === bid);
return b ? `<span class="tree-leaf-chip">${b.icon} ${b.name}</span>` : '';
})
.join('');
const isActive = uc.id === activeUCId;
const rightEl = isActive
? '<span class="tree-leaf-active-tag">Active</span>'
: '<span class="tree-leaf-arrow"></span>';
return `<div class="tree-leaf${isActive ? ' active-uc' : ''}" style="--uc-color:${uc.color}" onclick="pickUC('${uc.id}','${uc.color}')">
<div class="tree-indent">${indent}</div>
<div class="tree-leaf-inner">
<div class="tree-leaf-color"></div>
<span class="tree-leaf-name">${escHtml(uc.name)}</span>
<div class="tree-leaf-chips">${chips}</div>
${rightEl}
</div>
</div>`;
}
function renderNode(node, depth) {
let html = '';
(node._items || []).forEach((uc) => {
html += leafHtml(uc, depth);
});
Object.keys(node)
.filter((k) => k !== '_items')
.forEach((key) => {
const child = node[key];
const id = 'tg-' + ++_treeGid;
const count = countLeaves(child);
const indent = Array.from(
{ length: depth },
() => '<span class="tree-guide"></span>',
).join('');
html += `<div class="tree-folder">
<div class="tree-folder-hdr" onclick="toggleGroup('${id}')">
<div class="tree-indent">${indent}</div>
<span class="tree-arrow open" id="arr-${id}">▶</span>
<span class="tree-folder-icon">📁</span>
<span class="tree-folder-label">${escHtml(key)}</span>
<span class="tree-folder-count">${count}</span>
</div>
<div class="tree-children" id="${id}">${renderNode(child, depth + 1)}</div>
</div>`;
});
return html;
}
function toggleGroup(id) {
const body = document.getElementById(id);
const arr = document.getElementById('arr-' + id);
if (!body) return;
const collapsed = body.classList.toggle('collapsed');
if (arr) arr.classList.toggle('open', !collapsed);
}
// ── Selector list (tree view) ─────────────────────────────
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 || [];
if (!usecases.length) {
document.getElementById('ucList').innerHTML =
'<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">No usecases yet.<br>Create them in the desktop UI.</p>';
return;
}
// Build tree
_treeGid = 0;
const tree = { _items: [] };
usecases.forEach((uc) => {
const path = (uc.group || '').trim();
if (!path) {
tree._items.push(uc);
return;
}
insertIntoTree(tree, path.split(SEP).filter(Boolean), uc);
});
document.getElementById('ucList').innerHTML =
`<div class="tree-root">${renderNode(tree, 0)}</div>`;
}
async function pickUC(id, color) {
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` : '—';
if (paused && _overlayActive) hideOverlay();
}
// ── 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);
// ── PWA Overlay ───────────────────────────────────────────
let _overlayInterval = null;
let _overlayDismissTimer = null;
let _overlayActive = false;
function startOverlayPoll() {
clearInterval(_overlayInterval);
_overlayInterval = setInterval(pollOverlay, 1000);
pollOverlay();
}
async function pollOverlay() {
const r = await fetchJSON('/api/pwa/overlay').catch(() => null);
if (!r) return;
const el = document.getElementById('pwa-overlay');
const img = document.getElementById('pwa-overlay-img');
if (r.active && r.image) {
if (!_overlayActive) {
img.src = r.image;
el.classList.add('visible');
_overlayActive = true;
}
clearTimeout(_overlayDismissTimer);
if (r.remaining_ms > 0) {
_overlayDismissTimer = setTimeout(hideOverlay, r.remaining_ms);
}
} else {
hideOverlay();
}
}
function hideOverlay() {
clearTimeout(_overlayDismissTimer);
_overlayActive = false;
const el = document.getElementById('pwa-overlay');
el.classList.remove('visible');
setTimeout(() => {
if (!_overlayActive)
document.getElementById('pwa-overlay-img').src = '';
}, 400);
}
// ── Background Settings ───────────────────────────────────
let _bgBlur = 18,
_bgOpac = 0.72;
function applyBgSettings(blur, opac) {
_bgBlur = blur;
_bgOpac = opac;
let styleEl = document.getElementById('bg-dynamic-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'bg-dynamic-style';
document.head.appendChild(styleEl);
}
styleEl.textContent = `
#shared-bg-overlay.for-selector {
background: rgba(8,8,10,${opac}) !important;
backdrop-filter: blur(${blur}px) saturate(.7) !important;
-webkit-backdrop-filter: blur(${blur}px) saturate(.7) !important;
}
#shared-bg-overlay.for-app {
background: rgba(8,8,10,${Math.min(opac + 0.16, 0.97)}) !important;
}
`;
document.getElementById('blurSlider').value = blur;
document.getElementById('opacSlider').value = Math.round(opac * 100);
document.getElementById('blurVal').textContent = blur + 'px';
document.getElementById('opacVal').textContent =
Math.round(opac * 100) + '%';
}
function previewBg() {
const blur = parseInt(document.getElementById('blurSlider').value);
const opac =
parseInt(document.getElementById('opacSlider').value) / 100;
applyBgSettings(blur, opac);
}
function toggleBgSettings() {
document.getElementById('bg-settings-panel').classList.toggle('open');
}
async function saveBgSettings() {
const blur = parseInt(document.getElementById('blurSlider').value);
const opac =
parseInt(document.getElementById('opacSlider').value) / 100;
await postJSON('/api/settings', {
pwa_bg_blur: blur,
pwa_bg_opacity: opac,
}).catch(() => null);
applyBgSettings(blur, opac);
document.getElementById('bg-settings-panel').classList.remove('open');
showToast('Saved!');
}
if ('serviceWorker' in navigator)
navigator.serviceWorker.register('/pwa/sw.js');
init();
</script>
</body>
</html>