1080 lines
54 KiB
HTML
1080 lines
54 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 ── */
|
||
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); }
|
||
.idle .dot { background: var(--muted); }
|
||
|
||
/* ── Tab bar ── */
|
||
.tab-bar {
|
||
display: flex; padding: 0 28px;
|
||
border-bottom: 1px solid var(--border); background: var(--card);
|
||
}
|
||
.tab-btn {
|
||
padding: 10px 18px; font-size: .82rem; font-weight: 600; cursor: pointer;
|
||
border: none; background: none; color: var(--muted);
|
||
border-bottom: 2px solid transparent; transition: .15s; font-family: inherit;
|
||
}
|
||
.tab-btn:hover { color: var(--text); }
|
||
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||
|
||
/* ── Layout ── */
|
||
.tab-content { display: none; }
|
||
.tab-content.active { display: block; }
|
||
|
||
/* Hide everything when paused (matches V2 behaviour) */
|
||
body.is-paused header, body.is-paused main, body.is-paused #uc-picker { display: none; }
|
||
main { max-width: 1100px; margin: 0 auto; padding: 24px 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; }
|
||
|
||
/* ── Cards ── */
|
||
.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; }
|
||
|
||
/* ── Usecase picker overlay ── */
|
||
#uc-picker {
|
||
position: fixed; inset: 0; background: rgba(13,13,15,.97);
|
||
z-index: 200; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
padding: 40px 20px;
|
||
}
|
||
#uc-picker.hidden { display: none; }
|
||
.picker-logo { font-size: 1.4rem; font-weight: 700; color: var(--accent); letter-spacing: .05em; margin-bottom: 6px; }
|
||
.picker-sub { font-size: .78rem; color: var(--muted); margin-bottom: 32px; }
|
||
.picker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; width: 100%; max-width: 860px; }
|
||
.picker-card {
|
||
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
|
||
padding: 18px; cursor: pointer; transition: .18s; position: relative; overflow: hidden;
|
||
}
|
||
.picker-card::before { content:''; position:absolute; top:0; left:0; right:0; height:3px; background: var(--uc-color, var(--accent)); }
|
||
.picker-card:hover { border-color: var(--uc-color, var(--accent)); transform: translateY(-2px); box-shadow: 0 8px 28px rgba(0,0,0,.5); }
|
||
.picker-card:active { transform: scale(.98); }
|
||
.picker-name { font-size: 1rem; font-weight: 700; margin-bottom: 6px; }
|
||
.picker-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||
.picker-chip { padding: 2px 7px; border-radius: 4px; font-size: .67rem; font-family: var(--mono); font-weight: 600; background: rgba(79,142,247,.1); color: var(--accent); border: 1px solid rgba(79,142,247,.2); }
|
||
.picker-meta { font-size: .72rem; color: var(--muted); }
|
||
.picker-empty { color: var(--muted); font-size: .88rem; text-align: center; }
|
||
.picker-empty a { color: var(--accent); cursor: pointer; text-decoration: underline; }
|
||
|
||
/* ── Countdown ring ── */
|
||
#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.muted { color: var(--muted); }
|
||
.stat-val.accent { color: var(--accent); }
|
||
|
||
/* ── Buttons ── */
|
||
.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; font-family: inherit; }
|
||
.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-green { background: #162d1a; border: 1px solid var(--green); color: var(--green); }
|
||
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
|
||
/* ── Forms ── */
|
||
.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, .field select {
|
||
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; font-family: inherit;
|
||
}
|
||
.field input:focus, .field textarea:focus, .field select:focus { border-color: var(--accent); }
|
||
.field textarea { resize: vertical; min-height: 160px; font-family: var(--mono); font-size: .78rem; line-height: 1.55; }
|
||
.field input[type=color] { padding: 3px; height: 34px; cursor: pointer; }
|
||
.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); }
|
||
|
||
/* ── Toggles ── */
|
||
.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; }
|
||
|
||
/* ── Entries ── */
|
||
.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); }
|
||
|
||
/* ── Prognosis ── */
|
||
.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 ── */
|
||
.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); }
|
||
|
||
/* ── Toggle pill ── */
|
||
.card-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||
.display-mode-toggle { display:flex; gap:5px; }
|
||
.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); }
|
||
|
||
/* ── Info box ── */
|
||
.info-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 12px; }
|
||
.info-label { font-size: 10px; color: var(--muted); font-family: var(--mono); margin-bottom: 4px; }
|
||
.info-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; }
|
||
|
||
/* ── Usecase cards (Usecases tab) ── */
|
||
.uc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 14px; }
|
||
.uc-card { background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; position: relative; overflow: hidden; transition: border-color .15s; }
|
||
.uc-card::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:var(--uc-color, var(--accent)); }
|
||
.uc-card:hover { border-color: var(--uc-color, var(--accent)); }
|
||
.uc-card.is-active { border-color: var(--uc-color, var(--accent)); background: rgba(79,142,247,.04); }
|
||
.uc-name { font-size:.9rem; font-weight:700; margin-bottom:6px; display:flex; align-items:center; gap:6px; }
|
||
.uc-active-tag { font-size:.6rem; font-family:var(--mono); font-weight:700; color:var(--green); background:rgba(34,197,94,.1); border:1px solid rgba(34,197,94,.25); border-radius:4px; padding:1px 5px; }
|
||
.uc-block-chips { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:8px; }
|
||
.uc-chip { padding:2px 7px; border-radius:4px; font-size:.68rem; font-family:var(--mono); font-weight:600; background:rgba(79,142,247,.1); color:var(--accent); border:1px solid rgba(79,142,247,.2); }
|
||
.uc-chip.off { background:transparent; color:var(--muted); border-color:var(--border); }
|
||
.uc-meta { font-size:.72rem; color:var(--muted); margin-bottom:10px; }
|
||
.uc-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||
.uc-actions .btn { flex:none; padding:5px 10px; font-size:.74rem; border-radius:6px; }
|
||
|
||
/* ── Block selector ── */
|
||
.block-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-top: 6px; }
|
||
.block-opt { border:1px solid var(--border); border-radius:8px; padding:11px 12px; cursor:pointer; transition:.15s; background:var(--bg); position:relative; }
|
||
.block-opt:hover { border-color: var(--accent); }
|
||
.block-opt.sel { border-color: var(--accent); background: rgba(79,142,247,.08); }
|
||
.block-opt-icon { font-size:1.2rem; margin-bottom:5px; }
|
||
.block-opt-name { font-size:.8rem; font-weight:600; margin-bottom:2px; }
|
||
.block-opt-desc { font-size:.68rem; color:var(--muted); line-height:1.35; }
|
||
.block-check { position:absolute; top:8px; right:8px; width:14px; height:14px; border-radius:50%; border:2px solid var(--border); transition:.15s; }
|
||
.block-opt.sel .block-check { background:var(--accent); border-color:var(--accent); }
|
||
|
||
/* ── Modal ── */
|
||
.modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.75); display:none; align-items:center; justify-content:center; z-index:100; backdrop-filter:blur(3px); }
|
||
.modal-backdrop.open { display:flex; }
|
||
.modal { background:var(--card); border:1px solid var(--border); border-radius:14px; width:560px; max-width:95vw; max-height:90vh; overflow-y:auto; padding:22px; }
|
||
.modal-hdr { display:flex; align-items:center; justify-content:space-between; margin-bottom:18px; }
|
||
.modal-title { font-size:.95rem; font-weight:700; }
|
||
.modal-close { background:none; border:none; color:var(--muted); cursor:pointer; font-size:1.1rem; padding:4px 6px; border-radius:4px; }
|
||
.modal-close:hover { background:var(--bg); color:var(--text); }
|
||
.modal-footer { display:flex; gap:8px; justify-content:flex-end; margin-top:18px; padding-top:14px; border-top:1px solid var(--border); }
|
||
|
||
::-webkit-scrollbar { width: 5px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ── USECASE PICKER (shown on startup until one is chosen) ── -->
|
||
<div id="uc-picker">
|
||
<div class="picker-logo" id="picker-logo">NotifyPulse</div>
|
||
<div class="picker-sub">Select a usecase to start</div>
|
||
<div class="picker-grid" id="picker-grid">
|
||
<div class="picker-empty">Loading…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── MAIN APP ── -->
|
||
<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>
|
||
|
||
<div class="tab-bar">
|
||
<button class="tab-btn active" onclick="switchTab('dashboard',this)">Dashboard</button>
|
||
<button class="tab-btn" onclick="switchTab('usecases',this)">Usecases</button>
|
||
<button class="tab-btn" onclick="switchTab('settings',this)">Settings</button>
|
||
</div>
|
||
|
||
<!-- ── DASHBOARD ── -->
|
||
<div class="tab-content active" id="tab-dashboard">
|
||
<main>
|
||
|
||
<div class="card" id="db-card-next-notification">
|
||
<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">Select a usecase to begin</div>
|
||
</div>
|
||
<div class="stat-row">
|
||
<div class="stat">
|
||
<div class="stat-label">STATUS</div>
|
||
<div class="stat-val muted" id="s-status">Idle</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">USECASE</div>
|
||
<div class="stat-val accent" id="s-uc" style="font-size:.9rem">—</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat-label">ENTRIES</div>
|
||
<div class="stat-val" id="s-entries">—</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="db-card-controls">
|
||
<h2>Controls</h2>
|
||
<div class="btn-row" id="ctrl-main-btns">
|
||
<button class="btn btn-outline" onclick="showPicker()" style="flex:1" id="ctrl-btn-switch">🎯 Switch Usecase</button>
|
||
<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" id="ctrl-btn-fire">⚡ Fire Now</button>
|
||
</div>
|
||
|
||
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:14px" id="ctrl-desktop-tests">
|
||
<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>
|
||
<button class="btn btn-outline" onclick="testMobile()">📱 Mobile WP</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="ctrl-mobile-pwa">
|
||
<hr class="divider">
|
||
<h2 style="margin-bottom:12px">Mobile PWA</h2>
|
||
<div class="info-box">
|
||
<div class="info-label">PWA URL — open on your phone (same network)</div>
|
||
<div class="info-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>
|
||
</div>
|
||
|
||
<div class="card" id="db-card-active-entries">
|
||
<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>
|
||
|
||
<div class="card" id="db-card-prognosis">
|
||
<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" id="prog-timed-cell"><div class="label">Timed (this hour)</div><div class="val" id="prog-timed">–</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card full-width" id="db-card-log">
|
||
<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>
|
||
</div>
|
||
|
||
<!-- ── USECASES ── -->
|
||
<div class="tab-content" id="tab-usecases">
|
||
<main>
|
||
|
||
<div class="card full-width">
|
||
<div class="card-heading" style="margin-bottom:16px">
|
||
<h2 style="margin-bottom:0">Usecases</h2>
|
||
<button class="btn btn-primary" style="flex:0;padding:6px 14px;font-size:.8rem" onclick="openUCModal()">+ New Usecase</button>
|
||
</div>
|
||
<div class="uc-grid" id="uc-grid">Loading…</div>
|
||
</div>
|
||
|
||
<div class="card full-width" id="uc-notif-card" style="display:none">
|
||
<div class="card-heading" style="margin-bottom:10px">
|
||
<h2 style="margin-bottom:0">
|
||
Notifications —
|
||
<span id="uc-editor-name" style="color:var(--accent);text-transform:none;letter-spacing:0;font-size:.85rem">—</span>
|
||
</h2>
|
||
<div class="btn-row" style="gap:6px">
|
||
<button class="btn btn-primary" style="flex:0;padding:5px 12px;font-size:.78rem" onclick="saveUCNotifs()">💾 Save</button>
|
||
<button class="btn btn-outline" style="flex:0;padding:5px 12px;font-size:.78rem" onclick="loadUCNotifs()">↺ Reload</button>
|
||
</div>
|
||
</div>
|
||
<div class="info-box" style="margin-bottom:10px">
|
||
<div class="info-label">USECASE FOLDER</div>
|
||
<div class="info-val" id="uc-folder-path">—</div>
|
||
</div>
|
||
<div class="field" style="margin-bottom:6px">
|
||
<textarea id="uc-notif-textarea" spellcheck="false"
|
||
placeholder="# One entry per line Take a break! | 35% Drink water! | 30% change.wallpaper | 20% show.overlay | 15% Morning standup | 09:00"></textarea>
|
||
</div>
|
||
<div class="save-msg" id="uc-save-msg"></div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<!-- ── SETTINGS ── -->
|
||
<div class="tab-content" id="tab-settings">
|
||
<main>
|
||
|
||
<div class="card">
|
||
<h2>General</h2>
|
||
<div class="field">
|
||
<label>APP NAME (shown before a usecase is selected)</label>
|
||
<input id="s-appname" type="text" placeholder="NotifyPulse" oninput="markDirty()">
|
||
</div>
|
||
<div class="field">
|
||
<label>HOTKEY (Pause / Resume)</label>
|
||
<input id="s-hotkey" type="text" placeholder="F13" oninput="markDirty()">
|
||
</div>
|
||
<div class="field">
|
||
<label>LOG MAX ENTRIES</label>
|
||
<input id="s-log-max" type="number" min="10" max="1000" oninput="markDirty()">
|
||
</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 class="toggle-row">
|
||
<span class="toggle-label">Minimize to tray (instead of closing)</span>
|
||
<label class="toggle"><input type="checkbox" id="t-tray"><span class="slider"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Run on Windows startup</span>
|
||
<label class="toggle"><input type="checkbox" id="t-startup-reg"><span class="slider"></span></label>
|
||
</div>
|
||
<div class="toggle-row">
|
||
<span class="toggle-label">Confirm before deleting usecases</span>
|
||
<label class="toggle"><input type="checkbox" id="t-confirm-del"><span class="slider"></span></label>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>DEFAULT ENTRY DISPLAY MODE</label>
|
||
<select id="s-entry-display" onchange="markDirty()">
|
||
<option value="percent">Percentage (e.g. 35%)</option>
|
||
<option value="chance">Chance (e.g. 1 in 3)</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%">Save Settings</button>
|
||
<div class="save-msg" id="save-msg"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Notifications</h2>
|
||
<div class="field">
|
||
<label>TOAST DURATION (seconds)</label>
|
||
<input id="s-notif-dur" type="number" min="1" max="30" oninput="markDirty()">
|
||
</div>
|
||
<div class="btn-row" style="margin-top:12px">
|
||
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
|
||
</div>
|
||
<div class="save-msg" id="save-msg3"></div>
|
||
</div>
|
||
|
||
<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="field">
|
||
<label>MONITOR</label>
|
||
<select id="s-ovr-monitor" onchange="markDirty()">
|
||
<option value="0">Primary monitor</option>
|
||
<option value="-1">All monitors</option>
|
||
<option value="1">Monitor 1</option>
|
||
<option value="2">Monitor 2</option>
|
||
<option value="3">Monitor 3</option>
|
||
</select>
|
||
</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>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Wallpaper Settings</h2>
|
||
<div class="field">
|
||
<label>WALLPAPER FIT</label>
|
||
<select id="s-wp-fit" onchange="markDirty()">
|
||
<option value="fill">Fill (crop to fill screen)</option>
|
||
<option value="fit">Fit (letterbox/pillarbox)</option>
|
||
<option value="stretch">Stretch (ignore aspect ratio)</option>
|
||
<option value="center">Center (no scaling)</option>
|
||
<option value="tile">Tile</option>
|
||
</select>
|
||
</div>
|
||
<div class="btn-row" style="margin-top:12px">
|
||
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
|
||
</div>
|
||
<div class="save-msg" id="save-msg4"></div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<div class="modal-backdrop" id="uc-modal">
|
||
<div class="modal">
|
||
<div class="modal-hdr">
|
||
<span class="modal-title" id="uc-modal-title">New Usecase</span>
|
||
<button class="modal-close" onclick="closeUCModal()">✕</button>
|
||
</div>
|
||
<div class="two-col">
|
||
<div class="field">
|
||
<label>NAME</label>
|
||
<input type="text" id="uc-name" placeholder="e.g. Wellness, Work, Gaming…">
|
||
</div>
|
||
<div class="field">
|
||
<label>COLOR</label>
|
||
<input type="color" id="uc-color" value="#4f8ef7">
|
||
</div>
|
||
</div>
|
||
<div class="two-col">
|
||
<div class="field">
|
||
<label>MIN INTERVAL (min)</label>
|
||
<input type="number" id="uc-min" value="10" min="1">
|
||
</div>
|
||
<div class="field">
|
||
<label>MAX INTERVAL (min)</label>
|
||
<input type="number" id="uc-max" value="30" min="1">
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>ENABLED BLOCKS</label>
|
||
<div class="block-grid" id="block-grid"></div>
|
||
</div>
|
||
|
||
<div style="border-top:1px solid var(--border);padding-top:14px;margin-top:4px">
|
||
<div style="font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:10px">Dashboard Layout</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 16px;margin-bottom:12px">
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Next Notification</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-next-notification"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Controls</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-controls"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Active Entries</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-active-entries"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Prognosis</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-prognosis"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Log</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-log"><span class="slider"></span></label></div>
|
||
</div>
|
||
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:8px;margin-top:10px">Controls — Sub-features</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px 16px">
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Switch Usecase</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-ctrl-switch"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Pause / Resume</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-ctrl-pause"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Fire Now</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-ctrl-fire"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Desktop Tests</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-ctrl-desktop-tests"><span class="slider"></span></label></div>
|
||
<div class="toggle-row" style="margin:0"><span class="toggle-label" style="font-size:.78rem">Mobile PWA</span>
|
||
<label class="toggle"><input type="checkbox" id="ucl-ctrl-mobile-pwa"><span class="slider"></span></label></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="btn btn-outline" style="flex:0;padding:8px 16px" onclick="closeUCModal()">Cancel</button>
|
||
<button class="btn btn-primary" style="flex:0;padding:8px 16px" onclick="saveUsecase()">Save Usecase</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let state = {}, allBlocks = [], allUsecases = [], activeUCId = '';
|
||
let _logCache = [], settingsDirty = false;
|
||
let editingUCId = null, selectedUCId = null;
|
||
const ENTRY_MODE_KEY = 'entryDisplayMode';
|
||
let entryDisplayMode = localStorage.getItem(ENTRY_MODE_KEY) || 'percent';
|
||
|
||
// ── Tabs ──────────────────────────────────────────────────
|
||
function switchTab(name, btn) {
|
||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('tab-' + name).classList.add('active');
|
||
btn.classList.add('active');
|
||
if (name === 'usecases') renderUsecases();
|
||
}
|
||
|
||
// ── Picker ────────────────────────────────────────────────
|
||
function showPicker() {
|
||
renderPickerGrid();
|
||
document.getElementById('uc-picker').classList.remove('hidden');
|
||
}
|
||
|
||
function hidePicker() {
|
||
document.getElementById('uc-picker').classList.add('hidden');
|
||
}
|
||
|
||
function renderPickerGrid() {
|
||
// Update logo with settings app_name
|
||
const settingsName = state.settings_app_name || 'NotifyPulse';
|
||
document.getElementById('picker-logo').textContent = settingsName;
|
||
|
||
const grid = document.getElementById('picker-grid');
|
||
if (!allUsecases.length) {
|
||
grid.innerHTML = '<div class="picker-empty">No usecases yet.<br><a onclick="hidePicker();switchTab(\'usecases\',document.querySelectorAll(\'.tab-btn\')[1])">Create one in the Usecases tab →</a></div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = allUsecases.map(uc => {
|
||
const chips = (uc.blocks||[]).map(bid => {
|
||
const b = allBlocks.find(x => x.id === bid);
|
||
return b ? `<span class="picker-chip">${b.icon} ${b.name}</span>` : '';
|
||
}).join('');
|
||
return `<div class="picker-card" style="--uc-color:${uc.color}" onclick="pickUsecase('${uc.id}')">
|
||
<div class="picker-name">${escHtml(uc.name)}</div>
|
||
<div class="picker-chips">${chips}</div>
|
||
<div class="picker-meta">⏱ ${uc.min_interval}–${uc.max_interval} min</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function pickUsecase(id) {
|
||
await fetch('/api/usecases/' + id + '/activate', {method:'POST'});
|
||
hidePicker();
|
||
await fetchState();
|
||
}
|
||
|
||
// ── Fetch ─────────────────────────────────────────────────
|
||
async function fetchState() {
|
||
try {
|
||
const prevActiveId = activeUCId;
|
||
const [s, ucs] = await Promise.all([
|
||
fetch('/api/state').then(r => r.json()),
|
||
fetch('/api/usecases').then(r => r.json()),
|
||
]);
|
||
state = s;
|
||
allUsecases = ucs.usecases || [];
|
||
allBlocks = ucs.blocks || [];
|
||
activeUCId = ucs.active || '';
|
||
_logCache = state.log || [];
|
||
updateDashboard();
|
||
if (activeUCId !== prevActiveId) applyLayoutForActiveUC();
|
||
|
||
// Show picker on startup if no usecase is active yet
|
||
if (!activeUCId && !document.getElementById('uc-picker').classList.contains('hidden')) {
|
||
renderPickerGrid();
|
||
}
|
||
} catch(e) { console.error('fetchState error:', e); }
|
||
}
|
||
|
||
// ── Dashboard ─────────────────────────────────────────────
|
||
function updateDashboard() {
|
||
const paused = state.paused;
|
||
const uc = state.usecase || null;
|
||
const settingsName = state.settings_app_name || 'NotifyPulse';
|
||
|
||
// Header: show usecase name if active, otherwise settings app_name
|
||
const displayName = uc ? uc.name : settingsName;
|
||
document.getElementById('app-name').textContent = displayName;
|
||
document.title = paused ? 'Paused — ' + displayName : displayName;
|
||
|
||
// Hide header + main when paused (V2 behaviour)
|
||
document.body.classList.toggle('is-paused', !!paused);
|
||
|
||
// Update picker logo too
|
||
document.getElementById('picker-logo').textContent = settingsName;
|
||
|
||
// Status pill
|
||
const pill = document.getElementById('status-pill');
|
||
if (!activeUCId) {
|
||
pill.className = 'status-pill idle';
|
||
document.getElementById('status-text').textContent = 'No Usecase';
|
||
} else {
|
||
pill.className = 'status-pill ' + (paused ? 'paused' : 'running');
|
||
document.getElementById('status-text').textContent = paused ? 'Paused' : 'Running';
|
||
}
|
||
document.getElementById('pause-btn').textContent = paused ? '▶ Resume' : '⏸ Pause';
|
||
|
||
document.getElementById('s-status').textContent = !activeUCId ? 'Idle' : (paused ? 'Paused' : 'Running');
|
||
document.getElementById('s-status').className = 'stat-val ' + (!activeUCId ? 'muted' : paused ? 'red' : 'green');
|
||
document.getElementById('s-uc').textContent = uc ? uc.name : '—';
|
||
document.getElementById('s-entries').textContent = (state.entries || []).length || (activeUCId ? '0' : '—');
|
||
|
||
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;
|
||
|
||
const host = window.location.hostname;
|
||
const port = window.location.port || 5000;
|
||
document.getElementById('pwa-url').textContent = 'http://' + host + ':' + port + '/pwa';
|
||
|
||
// Ring
|
||
const hasNotifications = !activeUCId || (uc?.blocks || []).includes('notifications');
|
||
document.getElementById('countdown-ring').style.display = hasNotifications ? '' : 'none';
|
||
if (hasNotifications) {
|
||
const now = Date.now() / 1000;
|
||
const secsLeft = Math.max(0, (state.next_fire_at || 0) - now);
|
||
const maxMin = (uc?.max_interval || 30);
|
||
const totalInterval = maxMin * 60;
|
||
const pct = (activeUCId && totalInterval > 0) ? secsLeft / totalInterval : 0;
|
||
document.getElementById('arc').style.strokeDashoffset = 345.4 * (1 - pct);
|
||
const m = Math.floor(secsLeft / 60), s2 = Math.floor(secsLeft % 60);
|
||
if (!activeUCId) {
|
||
document.getElementById('cd-time').textContent = '--';
|
||
document.getElementById('next-label').textContent = 'Select a usecase to begin';
|
||
} else if (paused) {
|
||
document.getElementById('cd-time').textContent = '--';
|
||
document.getElementById('next-label').textContent = 'Paused — notifications suspended';
|
||
} else {
|
||
document.getElementById('cd-time').textContent = m + 'm ' + String(s2).padStart(2,'0') + 's';
|
||
document.getElementById('next-label').textContent = 'Next in ' + m + 'm ' + String(s2).padStart(2,'0') + 's';
|
||
}
|
||
}
|
||
|
||
if (!settingsDirty) {
|
||
const cfg = state.settings || {};
|
||
setVal('s-appname', cfg.app_name || 'NotifyPulse');
|
||
setVal('s-hotkey', cfg.hotkey || 'F13');
|
||
setVal('s-log-max', cfg.log_max_entries ?? 100);
|
||
setVal('s-notif-dur', cfg.notification_duration ?? 5);
|
||
setVal('s-ovr-dur', cfg.overlay_duration || 6);
|
||
setVal('s-ovr-opacity', cfg.overlay_opacity || 0.4);
|
||
setVal('s-ovr-monitor', cfg.overlay_monitor ?? 0);
|
||
setVal('s-wp-fit', cfg.wallpaper_fit || 'fill');
|
||
setVal('s-entry-display',cfg.entry_display_mode || 'percent');
|
||
document.getElementById('opacity-val').textContent = cfg.overlay_opacity || 0.4;
|
||
setCheck('t-startup', cfg.startup_toast !== false);
|
||
setCheck('t-sound', cfg.notify_sound !== false);
|
||
setCheck('t-browser', cfg.auto_open_browser !== false);
|
||
setCheck('t-stretch', !!cfg.overlay_stretch);
|
||
setCheck('t-tray', cfg.minimize_to_tray !== false);
|
||
setCheck('t-startup-reg', !!cfg.run_on_startup);
|
||
setCheck('t-confirm-del', cfg.confirm_delete !== false);
|
||
}
|
||
|
||
renderEntries(state.entries || []);
|
||
updatePrognosis(uc?.min_interval || 10, uc?.max_interval || 30, state.entries || [], uc?.blocks || []);
|
||
renderLog();
|
||
}
|
||
|
||
// ── Entries ───────────────────────────────────────────────
|
||
function renderEntries(entries) {
|
||
const totalWeight = entries.filter(e => e.weight != null).reduce((s, e) => s + e.weight, 0);
|
||
document.getElementById('entry-list').innerHTML = entries.length ? 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 w = Number(e.weight);
|
||
if (totalWeight > 0 && w > 0) {
|
||
if (entryDisplayMode === 'chance') {
|
||
const c = totalWeight / w;
|
||
tag = '<span class="entry-tag tag-pct">' + (c < 1.05 ? '1 in 1' : c < 10 ? '1 in ' + c.toFixed(1) : '1 in ' + Math.round(c)) + '</span>';
|
||
} else {
|
||
tag = '<span class="entry-tag tag-pct">' + (w / totalWeight * 100).toFixed(1) + '%</span>';
|
||
}
|
||
}
|
||
}
|
||
return '<li><span>' + escHtml(e.text) + '</span>' + tag + '</li>';
|
||
}).join('') : (activeUCId ? '<li style="color:var(--muted);font-size:.8rem">No entries in this usecase.</li>' : '<li style="color:var(--muted);font-size:.8rem">No usecase selected.</li>');
|
||
|
||
document.querySelectorAll('#display-mode-toggle [data-mode]').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.mode === entryDisplayMode));
|
||
}
|
||
|
||
document.getElementById('display-mode-toggle').addEventListener('click', e => {
|
||
const btn = e.target.closest('[data-mode]');
|
||
if (!btn) return;
|
||
entryDisplayMode = btn.dataset.mode;
|
||
localStorage.setItem(ENTRY_MODE_KEY, entryDisplayMode);
|
||
renderEntries(state.entries || []);
|
||
});
|
||
|
||
// ── Prognosis ─────────────────────────────────────────────
|
||
function updatePrognosis(minInt, maxInt, entries, blocks) {
|
||
const hasTimer = (blocks || []).includes('timer');
|
||
const timedCell = document.getElementById('prog-timed-cell');
|
||
timedCell.style.display = hasTimer ? '' : 'none';
|
||
|
||
if (!activeUCId) {
|
||
['prog-total','prog-random','prog-timed'].forEach(id => document.getElementById(id).textContent = '–');
|
||
document.getElementById('prog-range').textContent = '';
|
||
return;
|
||
}
|
||
const avg = ((minInt||10) + (maxInt||30)) / 2;
|
||
const rph = 60 / avg;
|
||
const now = new Date(), nowMins = now.getHours()*60+now.getMinutes(), endMins = nowMins+60;
|
||
let timed = 0;
|
||
if (hasTimer) {
|
||
for (const e of entries) {
|
||
if (e.trigger_time) {
|
||
const [hh,mm] = e.trigger_time.split(':').map(Number), 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' + (hasTimer ? ' + ' + timed + ' timed' : '');
|
||
}
|
||
|
||
// ── Log ───────────────────────────────────────────────────
|
||
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(); }
|
||
|
||
// ── Usecases tab ──────────────────────────────────────────
|
||
function renderUsecases() {
|
||
const grid = document.getElementById('uc-grid');
|
||
if (!allUsecases.length) {
|
||
grid.innerHTML = '<p style="color:var(--muted);font-size:.85rem">No usecases yet. Create one!</p>';
|
||
return;
|
||
}
|
||
grid.innerHTML = allUsecases.map(uc => {
|
||
const isActive = uc.id === activeUCId;
|
||
const chips = allBlocks.filter(b => (uc.blocks||[]).includes(b.id))
|
||
.map(b => `<span class="uc-chip">${b.icon} ${b.name}</span>`).join('');
|
||
return `<div class="uc-card ${isActive?'is-active':''}" style="--uc-color:${uc.color}">
|
||
<div class="uc-name">
|
||
<span>${escHtml(uc.name)}</span>
|
||
${isActive ? '<span class="uc-active-tag">● Active</span>' : ''}
|
||
</div>
|
||
<div class="uc-block-chips">${chips}</div>
|
||
<div class="uc-meta">⏱ ${uc.min_interval}–${uc.max_interval} min · ${(uc.notifications||[]).length} entries</div>
|
||
<div class="uc-actions">
|
||
${!isActive ? `<button class="btn btn-green" onclick="activateUC('${uc.id}')">▶ Activate</button>` : ''}
|
||
<button class="btn btn-outline" onclick="editNotifs('${uc.id}')">✏ Notifications</button>
|
||
<button class="btn btn-outline" onclick="openUCModal('${uc.id}')">⚙ Edit</button>
|
||
<button class="btn btn-outline" onclick="openFolder('${uc.id}')">📁 Folder</button>
|
||
${!isActive ? `<button class="btn btn-danger" onclick="deleteUC('${uc.id}')">🗑</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function activateUC(id) {
|
||
await fetch('/api/usecases/'+id+'/activate',{method:'POST'});
|
||
await fetchState(); renderUsecases();
|
||
}
|
||
async function openFolder(id) {
|
||
const r = await fetch('/api/usecases/'+id+'/open_folder',{method:'POST'}).then(r=>r.json());
|
||
if (selectedUCId === id) document.getElementById('uc-folder-path').textContent = r.path||'—';
|
||
}
|
||
async function deleteUC(id) {
|
||
if (!confirm('Delete this usecase?\n(The folder on disk is kept.)')) return;
|
||
await fetch('/api/usecases/'+id,{method:'DELETE'});
|
||
if (selectedUCId === id) { selectedUCId=null; document.getElementById('uc-notif-card').style.display='none'; }
|
||
await fetchState(); renderUsecases();
|
||
}
|
||
|
||
async function editNotifs(id) {
|
||
selectedUCId = id;
|
||
const uc = allUsecases.find(u=>u.id===id);
|
||
if (!uc) return;
|
||
document.getElementById('uc-editor-name').textContent = uc.name;
|
||
document.getElementById('uc-notif-card').style.display = 'block';
|
||
loadUCNotifs();
|
||
const r = await fetch('/api/usecases/'+id+'/open_folder',{method:'POST'}).then(r=>r.json()).catch(()=>({path:'—'}));
|
||
document.getElementById('uc-folder-path').textContent = r.path||'—';
|
||
}
|
||
function loadUCNotifs() {
|
||
const uc = allUsecases.find(u=>u.id===selectedUCId);
|
||
if (uc) document.getElementById('uc-notif-textarea').value = (uc.notifications||[]).join('\n');
|
||
}
|
||
async function saveUCNotifs() {
|
||
const msg = document.getElementById('uc-save-msg');
|
||
const lines = document.getElementById('uc-notif-textarea').value.split('\n').map(l=>l.trim()).filter(Boolean);
|
||
const r = await fetch('/api/usecases/'+selectedUCId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({notifications:lines})}).then(r=>r.json()).catch(()=>({ok:false}));
|
||
msg.textContent = r.ok ? '✓ Saved' : '✗ Error';
|
||
msg.className = 'save-msg'+(r.ok?'':' err');
|
||
setTimeout(()=>msg.textContent='',2500);
|
||
if (r.ok) { await fetchState(); renderUsecases(); }
|
||
}
|
||
|
||
// ── Usecase modal ─────────────────────────────────────────
|
||
function renderBlockGrid(selectedBlocks) {
|
||
document.getElementById('block-grid').innerHTML = allBlocks.map(b => {
|
||
const on = selectedBlocks.includes(b.id);
|
||
return `<div class="block-opt ${on?'sel':''}" onclick="this.classList.toggle('sel')" data-block="${b.id}">
|
||
<div class="block-opt-icon">${b.icon}</div>
|
||
<div class="block-opt-name">${b.name}</div>
|
||
<div class="block-opt-desc">${b.description}</div>
|
||
<div class="block-check"></div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
function getSelectedBlocks() {
|
||
return [...document.querySelectorAll('#block-grid .block-opt.sel')].map(el=>el.dataset.block);
|
||
}
|
||
function openUCModal(id=null) {
|
||
editingUCId = id;
|
||
const uc = id ? allUsecases.find(u=>u.id===id) : null;
|
||
document.getElementById('uc-modal-title').textContent = uc ? 'Edit — '+uc.name : 'New Usecase';
|
||
setVal('uc-name', uc?uc.name:'');
|
||
document.getElementById('uc-color').value = uc?(uc.color||'#4f8ef7'):'#4f8ef7';
|
||
setVal('uc-min', uc?uc.min_interval:10);
|
||
setVal('uc-max', uc?uc.max_interval:30);
|
||
renderBlockGrid(uc?(uc.blocks||[]):['notifications']);
|
||
renderUCLayoutToggles(uc);
|
||
document.getElementById('uc-modal').classList.add('open');
|
||
}
|
||
function closeUCModal() { document.getElementById('uc-modal').classList.remove('open'); editingUCId=null; }
|
||
async function saveUsecase() {
|
||
const name = getVal('uc-name').trim();
|
||
if (!name) { alert('Name required'); return; }
|
||
const savedEditingId = editingUCId;
|
||
const payload = { name, color:document.getElementById('uc-color').value, min_interval:parseInt(getVal('uc-min'))||10, max_interval:parseInt(getVal('uc-max'))||30, blocks:getSelectedBlocks(), dashboard_layout:getUCLayoutFromModal() };
|
||
const resp = await fetch(savedEditingId?'/api/usecases/'+savedEditingId:'/api/usecases',{method:savedEditingId?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json()).catch(e=>{console.error('saveUsecase error:',e);return {ok:false};});
|
||
if (!resp.ok) { alert('Error saving usecase'); return; }
|
||
closeUCModal();
|
||
await fetchState();
|
||
renderUsecases();
|
||
if (savedEditingId === activeUCId) applyLayoutForActiveUC();
|
||
}
|
||
|
||
// ── Settings ──────────────────────────────────────────────
|
||
function markDirty() { settingsDirty = true; }
|
||
async function saveSettings() {
|
||
const payload = {
|
||
app_name: getVal('s-appname'),
|
||
hotkey: getVal('s-hotkey'),
|
||
log_max_entries: parseInt(getVal('s-log-max')) || 100,
|
||
notification_duration: parseInt(getVal('s-notif-dur')) || 5,
|
||
overlay_duration: parseInt(getVal('s-ovr-dur')),
|
||
overlay_opacity: parseFloat(getVal('s-ovr-opacity')),
|
||
overlay_monitor: parseInt(getVal('s-ovr-monitor')),
|
||
overlay_stretch: getCheck('t-stretch'),
|
||
wallpaper_fit: getVal('s-wp-fit'),
|
||
entry_display_mode: getVal('s-entry-display'),
|
||
startup_toast: getCheck('t-startup'),
|
||
notify_sound: getCheck('t-sound'),
|
||
auto_open_browser: getCheck('t-browser'),
|
||
minimize_to_tray: getCheck('t-tray'),
|
||
run_on_startup: getCheck('t-startup-reg'),
|
||
confirm_delete: getCheck('t-confirm-del'),
|
||
};
|
||
const r = await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
|
||
['save-msg','save-msg2','save-msg3','save-msg4'].forEach(id=>{const el=document.getElementById(id);if(el){el.textContent=r.ok?'✓ Saved!':'✗ Error';el.className='save-msg'+(r.ok?'':' err');}});
|
||
settingsDirty=false;
|
||
setTimeout(()=>['save-msg','save-msg2','save-msg3','save-msg4'].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent='';}),2500);
|
||
fetchState();
|
||
}
|
||
|
||
// ── Actions ───────────────────────────────────────────────
|
||
async function togglePause() { await fetch('/api/pause',{method:'POST'}); 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 testMobile() { await fetch('/api/test_mobile_wallpaper',{method:'POST'}); }
|
||
|
||
// ── Helpers ───────────────────────────────────────────────
|
||
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,'>'); }
|
||
|
||
// ── Dashboard Layout (per-usecase) ────────────────────────
|
||
const DB_LAYOUT_DEFAULTS = {
|
||
'next-notification': true,
|
||
'controls': true,
|
||
'active-entries': true,
|
||
'prognosis': true,
|
||
'log': true,
|
||
'ctrl-switch': true,
|
||
'ctrl-pause': true,
|
||
'ctrl-fire': true,
|
||
'ctrl-desktop-tests': true,
|
||
'ctrl-mobile-pwa': true,
|
||
};
|
||
|
||
function getLayoutFromUC(uc) {
|
||
return Object.assign({}, DB_LAYOUT_DEFAULTS, uc?.dashboard_layout || {});
|
||
}
|
||
|
||
function applyLayoutForActiveUC() {
|
||
const uc = allUsecases.find(u => u.id === activeUCId);
|
||
const layout = uc ? getLayoutFromUC(uc) : {...DB_LAYOUT_DEFAULTS};
|
||
_applyLayoutToDOM(layout);
|
||
}
|
||
|
||
function _applyLayoutToDOM(layout) {
|
||
const cardMap = {
|
||
'next-notification': 'db-card-next-notification',
|
||
'controls': 'db-card-controls',
|
||
'active-entries': 'db-card-active-entries',
|
||
'prognosis': 'db-card-prognosis',
|
||
'log': 'db-card-log',
|
||
};
|
||
Object.entries(cardMap).forEach(([key, id]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = layout[key] !== false ? '' : 'none';
|
||
});
|
||
const ctrlMap = {
|
||
'ctrl-switch': 'ctrl-btn-switch',
|
||
'ctrl-pause': 'pause-btn',
|
||
'ctrl-fire': 'ctrl-btn-fire',
|
||
'ctrl-desktop-tests': 'ctrl-desktop-tests',
|
||
'ctrl-mobile-pwa': 'ctrl-mobile-pwa',
|
||
};
|
||
Object.entries(ctrlMap).forEach(([key, id]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = layout[key] !== false ? '' : 'none';
|
||
});
|
||
const anyMainBtn = layout['ctrl-switch'] !== false || layout['ctrl-pause'] !== false || layout['ctrl-fire'] !== false;
|
||
const mainRow = document.getElementById('ctrl-main-btns');
|
||
if (mainRow) mainRow.style.display = anyMainBtn ? '' : 'none';
|
||
}
|
||
|
||
function renderUCLayoutToggles(uc) {
|
||
const layout = getLayoutFromUC(uc);
|
||
Object.keys(DB_LAYOUT_DEFAULTS).forEach(k => {
|
||
const el = document.getElementById('ucl-' + k);
|
||
if (el) el.checked = layout[k] !== false;
|
||
});
|
||
}
|
||
|
||
function getUCLayoutFromModal() {
|
||
const layout = {};
|
||
Object.keys(DB_LAYOUT_DEFAULTS).forEach(k => {
|
||
const el = document.getElementById('ucl-' + k);
|
||
layout[k] = el ? el.checked : DB_LAYOUT_DEFAULTS[k];
|
||
});
|
||
return layout;
|
||
}
|
||
|
||
|
||
async function init() {
|
||
await fetchState();
|
||
applyLayoutForActiveUC();
|
||
// Show picker on startup only if no usecase is active
|
||
if (!activeUCId) {
|
||
showPicker();
|
||
} else {
|
||
hidePicker();
|
||
}
|
||
}
|
||
|
||
init();
|
||
setInterval(fetchState, 1000);
|
||
</script>
|
||
</body>
|
||
</html> |