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

1080 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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&#10;Take a break! | 35%&#10;Drink water! | 30%&#10;change.wallpaper | 20%&#10;show.overlay | 15%&#10;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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ── 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>