611 lines
29 KiB
HTML
611 lines
29 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>NotifyPulse</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--bg: #0d0d0f;
|
||
--card: #16161a;
|
||
--border: #2a2a32;
|
||
--accent: #4f8ef7;
|
||
--accent2: #7c5cfc;
|
||
--text: #e2e2e8;
|
||
--muted: #6b6b7b;
|
||
--green: #22c55e;
|
||
--red: #ef4444;
|
||
--yellow: #f59e0b;
|
||
--mono: 'Cascadia Code', 'Consolas', monospace;
|
||
}
|
||
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
|
||
|
||
header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--card);
|
||
}
|
||
header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: .03em; color: var(--accent); }
|
||
.header-right { display: flex; align-items: center; gap: 10px; }
|
||
.pwa-pill {
|
||
display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px;
|
||
background: var(--bg); border: 1px solid var(--border); padding: 4px 10px; border-radius: 20px; color: var(--muted);
|
||
}
|
||
.pwa-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); transition: .3s; }
|
||
.pwa-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||
.status-pill {
|
||
display: flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px;
|
||
font-size: .8rem; font-weight: 600; border: 1px solid var(--border);
|
||
}
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
.running .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||
.paused .dot { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
||
|
||
main { max-width: 1100px; margin: 0 auto; padding: 28px 20px; display: grid; gap: 20px; grid-template-columns: 1fr 1fr; }
|
||
@media(max-width: 700px) { main { grid-template-columns: 1fr; } }
|
||
.full-width { grid-column: 1 / -1; }
|
||
|
||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
|
||
.card h2 { font-size: .72rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-bottom: 14px; }
|
||
|
||
#countdown-ring { display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 8px 0; }
|
||
.ring-wrap { position: relative; width: 130px; height: 130px; }
|
||
.ring-wrap svg { transform: rotate(-90deg); }
|
||
.ring-bg { fill: none; stroke: var(--border); stroke-width: 10; }
|
||
.ring-arc { fill: none; stroke: var(--accent); stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset .9s linear; }
|
||
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||
.ring-label .big { font-size: 1.5rem; font-weight: 700; line-height: 1; color: var(--accent); }
|
||
.ring-label .small { font-size: .65rem; color: var(--muted); margin-top: 3px; }
|
||
#next-label { margin-top: 10px; font-size: .8rem; color: var(--muted); text-align: center; }
|
||
|
||
.stat-row { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-top: 14px; }
|
||
.stat { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||
.stat-label { font-size: 10px; color: var(--muted); font-family: var(--mono); letter-spacing: .06em; margin-bottom: 4px; }
|
||
.stat-val { font-size: 1.2rem; font-weight: 700; color: var(--text); }
|
||
.stat-val.green { color: var(--green); }
|
||
.stat-val.red { color: var(--red); }
|
||
.stat-val.accent { color: var(--accent); }
|
||
|
||
.btn { flex: 1; padding: 9px 14px; border: none; border-radius: 8px; cursor: pointer; font-size: .83rem; font-weight: 600; transition: opacity .15s, transform .1s; display: inline-flex; align-items: center; gap: 5px; justify-content: center; }
|
||
.btn:hover { opacity: .85; transform: translateY(-1px); }
|
||
.btn:active { transform: scale(.97); }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||
.btn-danger { background: #2d1a1a; border: 1px solid var(--red); color: var(--red); }
|
||
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
|
||
.field { margin-bottom: 13px; }
|
||
.field:last-child { margin-bottom: 0; }
|
||
.field label { display: block; font-size: .72rem; color: var(--muted); font-family: var(--mono); margin-bottom: 5px; letter-spacing: .04em; }
|
||
.field input[type=text], .field input[type=number], .field textarea {
|
||
width: 100%; padding: 8px 11px; background: var(--bg); border: 1px solid var(--border);
|
||
border-radius: 7px; color: var(--text); font-size: .85rem; outline: none; transition: border-color .15s;
|
||
}
|
||
.field input:focus, .field textarea:focus { border-color: var(--accent); }
|
||
.field textarea { resize: vertical; min-height: 160px; font-family: var(--mono); font-size: .78rem; line-height: 1.55; }
|
||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
.save-msg { font-size: .72rem; color: var(--green); min-height: 14px; margin-top: 5px; font-family: var(--mono); }
|
||
.save-msg.err { color: var(--red); }
|
||
|
||
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 11px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; margin-bottom: 8px; }
|
||
.toggle-row:last-child { margin-bottom: 0; }
|
||
.toggle-label { font-size: .83rem; color: var(--text); }
|
||
.toggle { position: relative; width: 38px; height: 21px; flex-shrink: 0; }
|
||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||
.slider { position: absolute; inset: 0; background: var(--border); border-radius: 11px; cursor: pointer; transition: .2s; }
|
||
.slider:before { content: ''; position: absolute; width: 15px; height: 15px; top: 3px; left: 3px; background: #fff; border-radius: 50%; transition: .2s; }
|
||
.toggle input:checked + .slider { background: var(--accent); }
|
||
.toggle input:checked + .slider:before { transform: translateX(17px); }
|
||
|
||
input[type=range] { -webkit-appearance: none; height: 4px; border-radius: 2px; background: var(--border); outline: none; width: 100%; margin-top: 6px; }
|
||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--accent); cursor: pointer; }
|
||
|
||
.entry-list { list-style: none; max-height: 220px; overflow-y: auto; }
|
||
.entry-list li { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: .82rem; gap: 10px; }
|
||
.entry-list li:last-child { border-bottom: none; }
|
||
.entry-tag { flex-shrink: 0; padding: 2px 7px; border-radius: 4px; font-size: .7rem; font-family: var(--mono); font-weight: 600; }
|
||
.tag-pct { background: rgba(79,142,247,.15); color: var(--accent); }
|
||
.tag-time { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||
.tag-special{ background: rgba(124,92,252,.15); color: var(--accent2); }
|
||
|
||
.prog-big { font-size: 2.4rem; font-weight: 700; color: var(--accent); line-height: 1; }
|
||
.prog-sub { font-size: .75rem; color: var(--muted); margin-top: 4px; }
|
||
.prog-row { display: flex; gap: 14px; margin-top: 14px; flex-wrap: wrap; }
|
||
.prog-cell { flex: 1; min-width: 80px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
|
||
.prog-cell .label { font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; }
|
||
.prog-cell .val { font-size: 1.15rem; font-weight: 700; color: var(--text); margin-top: 2px; }
|
||
|
||
.log-list { list-style: none; max-height: 240px; overflow-y: auto; }
|
||
.log-list li { display: grid; grid-template-columns: 52px 1fr; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: .78rem; }
|
||
.log-list li:last-child { border-bottom: none; }
|
||
.log-time { color: var(--muted); font-family: var(--mono); }
|
||
.log-msg { color: var(--text); }
|
||
.log-msg.error { color: var(--red); }
|
||
.log-msg.warn { color: var(--yellow); }
|
||
|
||
.card-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||
.display-mode-toggle { display:flex; gap:5px; align-items:center; }
|
||
.toggle-pill { border:none; border-radius:999px; padding:4px 10px; font-size:.7rem; font-weight:600; background: var(--bg); color: var(--muted); border:1px solid var(--border); cursor:pointer; transition:.2s; }
|
||
.toggle-pill.active { background: var(--accent); color:#fff; border-color: var(--accent); }
|
||
|
||
.pwa-url-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 12px; }
|
||
.pwa-url-label { font-size: 10px; color: var(--muted); font-family: var(--mono); margin-bottom: 4px; }
|
||
.pwa-url-val { font-family: var(--mono); font-size: .82rem; color: var(--accent); word-break: break-all; }
|
||
|
||
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
||
|
||
::-webkit-scrollbar { width: 5px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||
|
||
body.is-paused header, body.is-paused main { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1 id="app-name">NotifyPulse</h1>
|
||
<div class="header-right">
|
||
<div class="pwa-pill">
|
||
<div class="pwa-dot" id="pwa-dot"></div>
|
||
<span id="pwa-count">0 PWA</span>
|
||
</div>
|
||
<div id="status-pill" class="status-pill running">
|
||
<div class="dot"></div>
|
||
<span id="status-text">Running</span>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
|
||
<!-- Countdown -->
|
||
<div class="card">
|
||
<h2>Next Notification</h2>
|
||
<div id="countdown-ring">
|
||
<div class="ring-wrap">
|
||
<svg viewBox="0 0 130 130" width="130" height="130">
|
||
<circle class="ring-bg" cx="65" cy="65" r="55"/>
|
||
<circle class="ring-arc" cx="65" cy="65" r="55" id="arc"
|
||
stroke-dasharray="345.4" stroke-dashoffset="0"/>
|
||
</svg>
|
||
<div class="ring-label">
|
||
<span class="big" id="cd-time">--</span>
|
||
<span class="small" id="cd-label"></span>
|
||
</div>
|
||
</div>
|
||
<div id="next-label">Loading…</div>
|
||
</div>
|
||
<div class="stat-row">
|
||
<div class="stat">
|
||
<div class="stat-label">STATUS</div>
|
||
<div class="stat-val green" id="s-status">Running</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">INTERVAL</div>
|
||
<div class="stat-val accent" id="s-interval">—</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">ENTRIES</div>
|
||
<div class="stat-val" id="s-entries">—</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Controls -->
|
||
<div class="card">
|
||
<h2>Controls</h2>
|
||
<div class="btn-row">
|
||
<button class="btn btn-primary" onclick="togglePause()" id="pause-btn" style="flex:2">⏸ Pause / Resume</button>
|
||
<button class="btn btn-outline" onclick="fireNow()" style="flex:1">⚡ Fire Now</button>
|
||
</div>
|
||
|
||
<div style="margin-top:16px; border-top:1px solid var(--border); padding-top:14px">
|
||
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px">🖥️ Desktop Tests</div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-outline" onclick="testNotif()">🔔 Toast</button>
|
||
<button class="btn btn-outline" onclick="testWallpaper()">🖼️ Wallpaper</button>
|
||
<button class="btn btn-outline" onclick="testOverlay()">✨ Overlay</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:14px; border-top:1px solid var(--border); padding-top:14px">
|
||
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px">📱 Mobile Tests</div>
|
||
<div class="btn-row">
|
||
<button class="btn btn-outline" onclick="testMobileLockscreen()">🔒 Lockscreen WP</button>
|
||
<button class="btn btn-outline" onclick="testMobileBackground()">🏠 Home WP</button>
|
||
</div>
|
||
<div class="btn-row" style="margin-top:8px">
|
||
<button class="btn btn-outline" onclick="testMobileWallpaper()">📱 Both WPs</button>
|
||
<button class="btn btn-outline" onclick="testMobileNotif()">📳 Push Ping</button>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="divider">
|
||
|
||
<h2 style="margin-bottom:12px">Mobile PWA</h2>
|
||
<div class="pwa-url-box">
|
||
<div class="pwa-url-label">PWA URL — open on your phone (same network)</div>
|
||
<div class="pwa-url-val" id="pwa-url">Loading…</div>
|
||
</div>
|
||
<div class="stat" style="margin-top:10px">
|
||
<div class="stat-label">CONNECTED CLIENTS</div>
|
||
<div class="stat-val" id="client-count">0</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<div class="card">
|
||
<h2>Settings</h2>
|
||
<div class="two-col">
|
||
<div class="field">
|
||
<label>APP NAME</label>
|
||
<input id="s-name" type="text" placeholder="NotifyPulse" oninput="markDirty()">
|
||
</div>
|
||
<div class="field">
|
||
<label>HOTKEY</label>
|
||
<input id="s-hotkey" type="text" placeholder="F13" oninput="markDirty()">
|
||
</div>
|
||
<div class="field">
|
||
<label>MIN INTERVAL (min)</label>
|
||
<input id="s-min" type="number" min="1" placeholder="10" oninput="markDirty()">
|
||
</div>
|
||
<div class="field">
|
||
<label>MAX INTERVAL (min)</label>
|
||
<input id="s-max" type="number" min="1" placeholder="30" oninput="markDirty()">
|
||
</div>
|
||
</div>
|
||
<div style="margin: 14px 0 12px; border-top: 1px solid var(--border); padding-top: 12px;">
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Startup toast</span>
|
||
<label class="toggle"><input type="checkbox" id="t-startup"><span class="slider"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Notification sound</span>
|
||
<label class="toggle"><input type="checkbox" id="t-sound"><span class="slider"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Auto-open browser on start</span>
|
||
<label class="toggle"><input type="checkbox" id="t-browser"><span class="slider"></span></label>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%">Save Settings</button>
|
||
<div class="save-msg" id="save-msg"></div>
|
||
</div>
|
||
|
||
<!-- Overlay + Advanced -->
|
||
<div class="card">
|
||
<h2>Overlay Settings</h2>
|
||
<div class="two-col">
|
||
<div class="field">
|
||
<label>DEFAULT DURATION (sec)</label>
|
||
<input id="s-ovr-dur" type="number" min="1" max="300" oninput="markDirty()">
|
||
</div>
|
||
<div class="field">
|
||
<label>OPACITY — <span id="opacity-val">0.4</span></label>
|
||
<input type="range" id="s-ovr-opacity" min="0.05" max="1" step="0.05"
|
||
oninput="document.getElementById('opacity-val').textContent=this.value; markDirty()">
|
||
</div>
|
||
</div>
|
||
<div class="toggle-row" style="margin-top:10px">
|
||
<span class="toggle-label">Stretch to fill (vs letterbox fit)</span>
|
||
<label class="toggle"><input type="checkbox" id="t-stretch" onchange="markDirty()"><span class="slider"></span></label>
|
||
</div>
|
||
<div class="btn-row" style="margin-top:12px">
|
||
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
|
||
<button class="btn btn-outline" onclick="testOverlay()">Preview Overlay</button>
|
||
</div>
|
||
<div class="save-msg" id="save-msg2"></div>
|
||
|
||
<hr class="divider">
|
||
|
||
<h2 style="margin-bottom:12px">Advanced</h2>
|
||
<div class="field">
|
||
<label>PWA AUTH TOKEN (optional)</label>
|
||
<input type="text" id="s-token" placeholder="leave empty = no auth" oninput="markDirty()">
|
||
</div>
|
||
<div class="btn-row" style="margin-top:8px">
|
||
<button class="btn btn-danger" onclick="if(confirm('Reset all settings to defaults?'))resetSettings()">Reset to Defaults</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Active Entries -->
|
||
<div class="card">
|
||
<div class="card-heading">
|
||
<h2>Active Entries</h2>
|
||
<div class="display-mode-toggle" id="display-mode-toggle">
|
||
<button type="button" class="toggle-pill active" data-mode="percent">Percent</button>
|
||
<button type="button" class="toggle-pill" data-mode="chance">Chance</button>
|
||
</div>
|
||
</div>
|
||
<ul class="entry-list" id="entry-list"></ul>
|
||
</div>
|
||
|
||
<!-- Prognosis -->
|
||
<div class="card">
|
||
<h2>Prognosis — next hour</h2>
|
||
<div style="display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap">
|
||
<div>
|
||
<div class="prog-big" id="prog-total">–</div>
|
||
<div class="prog-sub">estimated notifications</div>
|
||
</div>
|
||
<div class="prog-sub" style="margin-bottom:5px;flex:1" id="prog-range"></div>
|
||
</div>
|
||
<div class="prog-row">
|
||
<div class="prog-cell"><div class="label">Random</div><div class="val" id="prog-random">–</div></div>
|
||
<div class="prog-cell"><div class="label">Timed (this hour)</div><div class="val" id="prog-timed">–</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Editor -->
|
||
<div class="card full-width">
|
||
<h2>Edit notifications.txt</h2>
|
||
<div class="pwa-url-box" style="margin-bottom:10px">
|
||
<div class="pwa-url-label">ACTIVE CONFIG FILE</div>
|
||
<div class="pwa-url-val" id="config-path">-</div>
|
||
</div>
|
||
<div class="btn-row" style="margin-bottom:10px">
|
||
<button class="btn btn-primary" style="flex:0" onclick="saveEditor()">💾 Save</button>
|
||
<button class="btn btn-outline" style="flex:0" onclick="loadEditor()">↺ Reload</button>
|
||
<span class="save-msg" id="editor-msg" style="margin-top:0;align-self:center"></span>
|
||
</div>
|
||
<div class="field" style="margin-bottom:0">
|
||
<textarea id="editor-textarea" spellcheck="false" placeholder="One notification per line."></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log -->
|
||
<div class="card full-width">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||
<h2 style="margin-bottom:0">Log</h2>
|
||
<button class="btn btn-outline" style="flex:0;padding:4px 10px;font-size:.7rem" onclick="clearLogUI()">Clear</button>
|
||
</div>
|
||
<ul class="log-list" id="log-list"></ul>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
let state = {};
|
||
let _logCache = [];
|
||
let totalInterval = 0;
|
||
let settingsDirty = false;
|
||
const ENTRY_DISPLAY_MODE_KEY = 'entryDisplayMode';
|
||
let entryDisplayMode = localStorage.getItem(ENTRY_DISPLAY_MODE_KEY) || 'percent';
|
||
|
||
function markDirty() { settingsDirty = true; }
|
||
|
||
function setEntryDisplayMode(mode) {
|
||
if (mode !== 'percent' && mode !== 'chance') return;
|
||
if (entryDisplayMode === mode) return;
|
||
entryDisplayMode = mode;
|
||
localStorage.setItem(ENTRY_DISPLAY_MODE_KEY, mode);
|
||
updateDisplayModeToggleButtons();
|
||
updateDisplayModeToggleButtons();
|
||
renderEntries(state.entries || []);
|
||
}
|
||
|
||
function updateDisplayModeToggleButtons() {
|
||
const toggle = document.getElementById('display-mode-toggle');
|
||
if (!toggle) return;
|
||
toggle.querySelectorAll('[data-mode]').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === entryDisplayMode);
|
||
});
|
||
}
|
||
|
||
function initDisplayModeToggle() {
|
||
const toggle = document.getElementById('display-mode-toggle');
|
||
if (!toggle) return;
|
||
toggle.addEventListener('click', (event) => {
|
||
const btn = event.target.closest('[data-mode]');
|
||
if (!btn) return;
|
||
setEntryDisplayMode(btn.dataset.mode);
|
||
});
|
||
updateDisplayModeToggleButtons();
|
||
}
|
||
|
||
async function fetchState() {
|
||
try {
|
||
const r = await fetch('/api/state');
|
||
state = await r.json();
|
||
_logCache = state.log || [];
|
||
updateUI();
|
||
} catch(e) {}
|
||
}
|
||
|
||
function updateUI() {
|
||
const paused = state.paused;
|
||
const appName = state.app_name || 'NotifyPulse';
|
||
document.getElementById('app-name').textContent = appName;
|
||
document.title = paused ? 'Paused' : appName;
|
||
document.body.classList.toggle('is-paused', paused);
|
||
|
||
const pill = document.getElementById('status-pill');
|
||
pill.className = 'status-pill ' + (paused ? 'paused' : 'running');
|
||
document.getElementById('status-text').textContent = paused ? 'Paused' : 'Running';
|
||
|
||
document.getElementById('s-status').textContent = paused ? 'Paused' : 'Running';
|
||
document.getElementById('s-status').className = 'stat-val ' + (paused ? 'red' : 'green');
|
||
document.getElementById('s-interval').textContent = (state.min_interval||'?') + '–' + (state.max_interval||'?') + ' min';
|
||
document.getElementById('s-entries').textContent = (state.entries||[]).length;
|
||
|
||
const pwa = state.pwa_clients || 0;
|
||
document.getElementById('pwa-count').textContent = pwa + ' PWA';
|
||
document.getElementById('pwa-dot').classList.toggle('on', pwa > 0);
|
||
document.getElementById('client-count').textContent = pwa;
|
||
document.getElementById('config-path').textContent = state.config_path || '-';
|
||
const host = window.location.hostname;
|
||
const port = window.location.port || 5000;
|
||
document.getElementById('pwa-url').textContent = 'http://' + host + ':' + port + '/pwa';
|
||
|
||
// Countdown ring
|
||
const now = Date.now() / 1000;
|
||
const secsLeft = Math.max(0, (state.next_fire_at || 0) - now);
|
||
totalInterval = state.total_interval || ((state.max_interval || 30) * 60);
|
||
const pct = totalInterval > 0 ? secsLeft / totalInterval : 1;
|
||
document.getElementById('arc').style.strokeDashoffset = 345.4 * (1 - pct);
|
||
const m = Math.floor(secsLeft / 60);
|
||
const s = Math.floor(secsLeft % 60);
|
||
document.getElementById('cd-time').textContent = paused ? '--' : (m + 'm ' + String(s).padStart(2,'0') + 's');
|
||
document.getElementById('next-label').textContent = paused
|
||
? 'Paused — notifications suspended'
|
||
: 'Next in ' + m + 'm ' + String(s).padStart(2,'0') + 's';
|
||
|
||
document.getElementById('pause-btn').textContent = paused ? '▶ Resume' : '⏸ Pause';
|
||
|
||
if (!settingsDirty) {
|
||
const s2 = state.settings || {};
|
||
setVal('s-name', s2.name || state.app_name || '');
|
||
setVal('s-hotkey', s2.hotkey || 'F13');
|
||
setVal('s-min', state.min_interval || 10);
|
||
setVal('s-max', state.max_interval || 30);
|
||
setVal('s-ovr-dur', s2.overlay_duration || 6);
|
||
setVal('s-ovr-opacity', s2.overlay_opacity || 0.4);
|
||
document.getElementById('opacity-val').textContent = s2.overlay_opacity || 0.4;
|
||
setVal('s-token', s2.pwa_token || '');
|
||
setCheck('t-startup', s2.startup_toast !== false);
|
||
setCheck('t-sound', s2.notify_sound !== false);
|
||
setCheck('t-browser', s2.auto_open_browser !== false);
|
||
setCheck('t-stretch', !!s2.overlay_stretch);
|
||
}
|
||
|
||
renderEntries(state.entries || []);
|
||
updatePrognosis(state.min_interval, state.max_interval, state.entries || []);
|
||
renderLog();
|
||
}
|
||
|
||
function renderEntries(entries) {
|
||
const totalWeight = entries.filter(e => e.weight != null).reduce((s,e) => s + e.weight, 0);
|
||
document.getElementById('entry-list').innerHTML = entries.map(e => {
|
||
let tag = '';
|
||
if (e.trigger_time) {
|
||
tag = '<span class="entry-tag tag-time">⏰ ' + e.trigger_time + '</span>';
|
||
} else if (/wallpaper|overlay/i.test(e.text)) {
|
||
tag = '<span class="entry-tag tag-special">' + (/wallpaper/i.test(e.text) ? '🖼 wallpaper' : '✨ overlay') + '</span>';
|
||
} else if (e.weight != null) {
|
||
const weight = Number(e.weight);
|
||
if (totalWeight > 0 && weight > 0) {
|
||
if (entryDisplayMode === 'chance') {
|
||
const chance = totalWeight / weight;
|
||
tag = '<span class="entry-tag tag-pct">' + formatChanceLabel(chance) + '</span>';
|
||
} else {
|
||
const pct = weight / totalWeight * 100;
|
||
tag = '<span class="entry-tag tag-pct">' + pct.toFixed(1) + '%</span>';
|
||
}
|
||
}
|
||
}
|
||
return '<li><span>' + escHtml(e.text) + '</span>' + tag + '</li>';
|
||
}).join('');
|
||
}
|
||
|
||
function formatChanceLabel(value) {
|
||
if (!isFinite(value) || value <= 0) return '1 in ?';
|
||
if (value < 2) return '1 in 1';
|
||
if (value < 10) return '1 in ' + value.toFixed(1);
|
||
return '1 in ' + Math.round(value);
|
||
}
|
||
|
||
function updatePrognosis(minInt, maxInt, entries) {
|
||
const avg = ((minInt||10) + (maxInt||30)) / 2;
|
||
const rph = 60 / avg;
|
||
const now = new Date();
|
||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||
const endMins = nowMins + 60;
|
||
let timed = 0;
|
||
for (const e of entries) {
|
||
if (e.trigger_time) {
|
||
const [hh,mm] = e.trigger_time.split(':').map(Number);
|
||
const em = hh * 60 + mm;
|
||
if (endMins >= 1440) { if (em >= nowMins || em < endMins - 1440) timed++; }
|
||
else { if (em >= nowMins && em < endMins) timed++; }
|
||
}
|
||
}
|
||
document.getElementById('prog-total').textContent = (rph + timed).toFixed(1);
|
||
document.getElementById('prog-random').textContent = rph.toFixed(1);
|
||
document.getElementById('prog-timed').textContent = timed;
|
||
document.getElementById('prog-range').textContent =
|
||
Math.floor(60/(maxInt||30)) + '–' + Math.ceil(60/(minInt||10)) + ' random + ' + timed + ' timed';
|
||
}
|
||
|
||
function renderLog() {
|
||
document.getElementById('log-list').innerHTML = (_logCache||[]).map(e =>
|
||
'<li><span class="log-time">' + e.time + '</span><span class="log-msg ' + (e.level||'info') + '">' + escHtml(e.msg) + '</span></li>'
|
||
).join('');
|
||
}
|
||
|
||
function clearLogUI() { _logCache = []; renderLog(); }
|
||
|
||
async function togglePause() {
|
||
const r = await fetch('/api/pause', {method:'POST'});
|
||
const d = await r.json();
|
||
document.body.classList.toggle('is-paused', d.paused);
|
||
document.getElementById('status-pill').className = 'status-pill ' + (d.paused ? 'paused' : 'running');
|
||
document.getElementById('status-text').textContent = d.paused ? 'Paused' : 'Running';
|
||
document.getElementById('pause-btn').textContent = d.paused ? '▶ Resume' : '⏸ Pause';
|
||
fetchState();
|
||
}
|
||
async function fireNow() { await fetch('/api/fire_now',{method:'POST'}); fetchState(); }
|
||
async function testNotif() { await fetch('/api/test_notification',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'Test from Web UI!'})}); }
|
||
async function testWallpaper() { await fetch('/api/test_wallpaper',{method:'POST'}); }
|
||
async function testOverlay() { await fetch('/api/test_overlay',{method:'POST'}); }
|
||
async function testMobileWallpaper() { await fetch('/api/test_mobile_wallpaper',{method:'POST'}); }
|
||
async function testMobileLockscreen(){ await fetch('/api/test_mobile_wallpaper',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'lockscreen'})}); }
|
||
async function testMobileBackground(){ await fetch('/api/test_mobile_wallpaper',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'background'})}); }
|
||
async function testMobileNotif() { await fetch('/api/test_notification',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'Mobile ping test!',mobile:true})}); }
|
||
|
||
async function loadEditor() {
|
||
try { const r = await fetch('/api/entries'); const d = await r.json(); document.getElementById('editor-textarea').value = d.content||''; } catch(e){}
|
||
}
|
||
async function saveEditor() {
|
||
const msg = document.getElementById('editor-msg');
|
||
try {
|
||
const r = await fetch('/api/entries',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:document.getElementById('editor-textarea').value})});
|
||
const d = await r.json();
|
||
msg.textContent = d.ok ? '✓ Saved' : '✗ Error'; msg.className='save-msg'+(d.ok?'':' err');
|
||
setTimeout(()=>{msg.textContent='';},3000);
|
||
if (d.ok) fetchState();
|
||
} catch(e){ msg.textContent='✗ Network error'; msg.className='save-msg err'; }
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const payload = {
|
||
name: getVal('s-name'), hotkey: getVal('s-hotkey'),
|
||
min_interval: parseInt(getVal('s-min')), max_interval: parseInt(getVal('s-max')),
|
||
overlay_duration: parseInt(getVal('s-ovr-dur')), overlay_opacity: parseFloat(getVal('s-ovr-opacity')),
|
||
overlay_stretch: getCheck('t-stretch'), startup_toast: getCheck('t-startup'),
|
||
notify_sound: getCheck('t-sound'), auto_open_browser: getCheck('t-browser'),
|
||
pwa_token: getVal('s-token'),
|
||
};
|
||
try {
|
||
const r = await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||
const d = await r.json();
|
||
['save-msg','save-msg2'].forEach(id=>{
|
||
const el=document.getElementById(id);
|
||
if(el){el.textContent=d.ok?'✓ Saved!':'✗ Error';el.className='save-msg'+(d.ok?'':' err');}
|
||
});
|
||
settingsDirty = false;
|
||
setTimeout(()=>['save-msg','save-msg2'].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent='';}),2500);
|
||
fetchState();
|
||
} catch(e){}
|
||
}
|
||
|
||
async function resetSettings() {
|
||
await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({overlay_stretch:false,overlay_opacity:0.4,overlay_duration:6,startup_toast:true,notify_sound:true,auto_open_browser:true})});
|
||
settingsDirty=false; fetchState();
|
||
}
|
||
|
||
function setVal(id,v){const el=document.getElementById(id);if(el)el.value=v;}
|
||
function setCheck(id,v){const el=document.getElementById(id);if(el)el.checked=v;}
|
||
function getVal(id){return document.getElementById(id)?.value;}
|
||
function getCheck(id){return document.getElementById(id)?.checked;}
|
||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||
|
||
fetchState();
|
||
loadEditor();
|
||
initDisplayModeToggle();
|
||
setInterval(fetchState, 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|