2613 lines
68 KiB
HTML
2613 lines
68 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: 0.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: 0.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: 0.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: 0.82rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: none;
|
||
background: none;
|
||
color: var(--muted);
|
||
border-bottom: 2px solid transparent;
|
||
transition: 0.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: 0.72rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
color: var(--muted);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
/* ── Usecase picker overlay ── */
|
||
#uc-picker {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(13, 13, 15, 0.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: 0.05em;
|
||
margin-bottom: 6px;
|
||
}
|
||
.picker-sub {
|
||
font-size: 0.78rem;
|
||
color: var(--muted);
|
||
margin-bottom: 32px;
|
||
}
|
||
.picker-grid {
|
||
display: block;
|
||
width: 100%;
|
||
max-width: 680px;
|
||
}
|
||
.picker-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 18px;
|
||
cursor: pointer;
|
||
transition: 0.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, 0.5);
|
||
}
|
||
.picker-card:active {
|
||
transform: scale(0.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: 0.67rem;
|
||
font-family: var(--mono);
|
||
font-weight: 600;
|
||
background: rgba(79, 142, 247, 0.1);
|
||
color: var(--accent);
|
||
border: 1px solid rgba(79, 142, 247, 0.2);
|
||
}
|
||
.picker-meta {
|
||
font-size: 0.72rem;
|
||
color: var(--muted);
|
||
}
|
||
.picker-empty {
|
||
color: var(--muted);
|
||
font-size: 0.88rem;
|
||
text-align: center;
|
||
}
|
||
.picker-empty a {
|
||
color: var(--accent);
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* ── Folder-tree picker ── */
|
||
#uc-picker .picker-grid {
|
||
display: block;
|
||
width: 100%;
|
||
max-width: 680px;
|
||
}
|
||
|
||
.tree-root {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
/* Folder row */
|
||
.tree-folder {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.tree-folder-hdr {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
padding: 6px 10px 6px 0;
|
||
cursor: pointer;
|
||
border-radius: 7px;
|
||
user-select: none;
|
||
transition: background 0.12s;
|
||
}
|
||
.tree-folder-hdr:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
/* Indent guides */
|
||
.tree-indent {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
}
|
||
.tree-guide {
|
||
width: 20px;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
.tree-guide::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: 50%;
|
||
width: 1px;
|
||
background: var(--border);
|
||
}
|
||
.tree-guide.last::before {
|
||
bottom: 50%;
|
||
}
|
||
|
||
/* Arrow + icon */
|
||
.tree-arrow {
|
||
width: 18px;
|
||
height: 18px;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.6rem;
|
||
color: var(--muted);
|
||
transition: transform 0.18s;
|
||
}
|
||
.tree-arrow.open {
|
||
transform: rotate(90deg);
|
||
}
|
||
.tree-folder-icon {
|
||
font-size: 0.95rem;
|
||
margin-right: 7px;
|
||
flex-shrink: 0;
|
||
}
|
||
.tree-folder-label {
|
||
font-size: 0.82rem;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
flex: 1;
|
||
}
|
||
.tree-folder-count {
|
||
font-size: 0.62rem;
|
||
color: var(--muted);
|
||
font-family: var(--mono);
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
padding: 1px 7px;
|
||
margin-left: 8px;
|
||
}
|
||
.tree-children {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.tree-children.collapsed {
|
||
display: none;
|
||
}
|
||
|
||
/* Leaf = usecase item */
|
||
.tree-leaf {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
cursor: pointer;
|
||
border-radius: 7px;
|
||
transition: background 0.12s;
|
||
}
|
||
.tree-leaf:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
.tree-leaf:active {
|
||
transform: scale(0.99);
|
||
}
|
||
.tree-leaf-inner {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 10px 7px 4px;
|
||
min-width: 0;
|
||
border-left: 2px solid var(--uc-color, var(--accent));
|
||
border-radius: 0 6px 6px 0;
|
||
margin-left: 2px;
|
||
}
|
||
.tree-leaf-color {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
background: var(--uc-color, var(--accent));
|
||
box-shadow: 0 0 6px var(--uc-color, var(--accent));
|
||
}
|
||
.tree-leaf-name {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
flex: 1;
|
||
min-width: 0;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.tree-leaf-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 3px;
|
||
flex-shrink: 0;
|
||
}
|
||
.tree-leaf-chip {
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
font-size: 0.62rem;
|
||
font-family: var(--mono);
|
||
font-weight: 600;
|
||
background: rgba(79, 142, 247, 0.1);
|
||
color: var(--accent);
|
||
border: 1px solid rgba(79, 142, 247, 0.2);
|
||
}
|
||
.tree-leaf-meta {
|
||
font-size: 0.68rem;
|
||
color: var(--muted);
|
||
flex-shrink: 0;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── 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 0.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: 0.65rem;
|
||
color: var(--muted);
|
||
margin-top: 3px;
|
||
}
|
||
#next-label {
|
||
margin-top: 10px;
|
||
font-size: 0.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: 0.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: 0.83rem;
|
||
font-weight: 600;
|
||
transition:
|
||
opacity 0.15s,
|
||
transform 0.1s;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
justify-content: center;
|
||
font-family: inherit;
|
||
}
|
||
.btn:hover {
|
||
opacity: 0.85;
|
||
transform: translateY(-1px);
|
||
}
|
||
.btn:active {
|
||
transform: scale(0.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: 0.72rem;
|
||
color: var(--muted);
|
||
font-family: var(--mono);
|
||
margin-bottom: 5px;
|
||
letter-spacing: 0.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: 0.85rem;
|
||
outline: none;
|
||
transition: border-color 0.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: 0.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: 0.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: 0.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: 0.2s;
|
||
}
|
||
.slider:before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 15px;
|
||
height: 15px;
|
||
top: 3px;
|
||
left: 3px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
transition: 0.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: 0.82rem;
|
||
gap: 10px;
|
||
}
|
||
.entry-list li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.entry-tag {
|
||
flex-shrink: 0;
|
||
padding: 2px 7px;
|
||
border-radius: 4px;
|
||
font-size: 0.7rem;
|
||
font-family: var(--mono);
|
||
font-weight: 600;
|
||
}
|
||
.tag-pct {
|
||
background: rgba(79, 142, 247, 0.15);
|
||
color: var(--accent);
|
||
}
|
||
.tag-time {
|
||
background: rgba(245, 158, 11, 0.15);
|
||
color: var(--yellow);
|
||
}
|
||
.tag-special {
|
||
background: rgba(124, 92, 252, 0.15);
|
||
color: var(--accent2);
|
||
}
|
||
|
||
/* ── Prognosis ── */
|
||
.prog-big {
|
||
font-size: 2.4rem;
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
line-height: 1;
|
||
}
|
||
.prog-sub {
|
||
font-size: 0.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: 0.7rem;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.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: 0.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: 0.7rem;
|
||
font-weight: 600;
|
||
background: var(--bg);
|
||
color: var(--muted);
|
||
border: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: 0.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: 0.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 0.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, 0.04);
|
||
}
|
||
.uc-name {
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
margin-bottom: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.uc-active-tag {
|
||
font-size: 0.6rem;
|
||
font-family: var(--mono);
|
||
font-weight: 700;
|
||
color: var(--green);
|
||
background: rgba(34, 197, 94, 0.1);
|
||
border: 1px solid rgba(34, 197, 94, 0.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: 0.68rem;
|
||
font-family: var(--mono);
|
||
font-weight: 600;
|
||
background: rgba(79, 142, 247, 0.1);
|
||
color: var(--accent);
|
||
border: 1px solid rgba(79, 142, 247, 0.2);
|
||
}
|
||
.uc-chip.off {
|
||
background: transparent;
|
||
color: var(--muted);
|
||
border-color: var(--border);
|
||
}
|
||
.uc-meta {
|
||
font-size: 0.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: 0.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: 0.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, 0.08);
|
||
}
|
||
.block-opt-icon {
|
||
font-size: 1.2rem;
|
||
margin-bottom: 5px;
|
||
}
|
||
.block-opt-name {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
margin-bottom: 2px;
|
||
}
|
||
.block-opt-desc {
|
||
font-size: 0.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: 0.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, 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: 0.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: 0.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: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.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: 0.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: 0.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: 0.85rem;
|
||
"
|
||
>—</span
|
||
>
|
||
</h2>
|
||
<div class="btn-row" style="gap: 6px">
|
||
<button
|
||
class="btn btn-primary"
|
||
style="flex: 0; padding: 5px 12px; font-size: 0.78rem"
|
||
onclick="saveUCNotifs()"
|
||
>
|
||
💾 Save
|
||
</button>
|
||
<button
|
||
class="btn btn-outline"
|
||
style="flex: 0; padding: 5px 12px; font-size: 0.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="field">
|
||
<label
|
||
>GROUP PATH (optional — use › to nest, e.g.
|
||
<em>Holiday › Summer</em>)</label
|
||
>
|
||
<input
|
||
type="text"
|
||
id="uc-group"
|
||
placeholder="e.g. Holiday or Holiday › Summer"
|
||
list="uc-group-list"
|
||
/>
|
||
<datalist id="uc-group-list"></datalist>
|
||
</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: 0.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.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: 0.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: 0.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: 0.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: 0.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: 0.78rem">Log</span>
|
||
<label class="toggle"
|
||
><input type="checkbox" id="ucl-log" /><span
|
||
class="slider"
|
||
></span
|
||
></label>
|
||
</div>
|
||
</div>
|
||
<div
|
||
style="
|
||
font-size: 0.68rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.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: 0.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: 0.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: 0.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: 0.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: 0.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() {
|
||
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;
|
||
}
|
||
|
||
const SEP = /\s*[›>]\s*/;
|
||
let gid = 0;
|
||
|
||
function insertIntoTree(node, parts, uc) {
|
||
if (!parts.length) {
|
||
node._items.push(uc);
|
||
return;
|
||
}
|
||
const k = parts[0];
|
||
if (!node[k]) node[k] = { _items: [] };
|
||
insertIntoTree(node[k], parts.slice(1), uc);
|
||
}
|
||
|
||
const tree = { _items: [] };
|
||
allUsecases.forEach((uc) => {
|
||
const path = (uc.group || '').trim();
|
||
if (!path) {
|
||
tree._items.push(uc);
|
||
return;
|
||
}
|
||
insertIntoTree(tree, path.split(SEP).filter(Boolean), uc);
|
||
});
|
||
|
||
function countLeaves(node) {
|
||
let n = (node._items || []).length;
|
||
Object.keys(node)
|
||
.filter((k) => k !== '_items')
|
||
.forEach((k) => (n += countLeaves(node[k])));
|
||
return n;
|
||
}
|
||
|
||
function leafHtml(uc, depth) {
|
||
const indent = Array.from(
|
||
{ length: depth },
|
||
() => '<span class="tree-guide"></span>',
|
||
).join('');
|
||
const chips = (uc.blocks || [])
|
||
.slice(0, 3)
|
||
.map((bid) => {
|
||
const b = allBlocks.find((x) => x.id === bid);
|
||
return b
|
||
? `<span class="tree-leaf-chip">${b.icon} ${b.name}</span>`
|
||
: '';
|
||
})
|
||
.join('');
|
||
return `<div class="tree-leaf" onclick="pickUsecase('${uc.id}')">
|
||
<div class="tree-indent">${indent}</div>
|
||
<div class="tree-leaf-inner" style="--uc-color:${uc.color}">
|
||
<div class="tree-leaf-color"></div>
|
||
<span class="tree-leaf-name">${escHtml(uc.name)}</span>
|
||
<div class="tree-leaf-chips">${chips}</div>
|
||
<span class="tree-leaf-meta">⏱ ${uc.min_interval}–${uc.max_interval}m</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderNode(node, depth) {
|
||
let html = '';
|
||
(node._items || []).forEach((uc) => {
|
||
html += leafHtml(uc, depth);
|
||
});
|
||
Object.keys(node)
|
||
.filter((k) => k !== '_items')
|
||
.forEach((key) => {
|
||
const child = node[key];
|
||
const id = 'tg-' + ++gid;
|
||
const count = countLeaves(child);
|
||
const indent = Array.from(
|
||
{ length: depth },
|
||
() => '<span class="tree-guide"></span>',
|
||
).join('');
|
||
html += `<div class="tree-folder">
|
||
<div class="tree-folder-hdr" onclick="toggleGroup('${id}')">
|
||
<div class="tree-indent">${indent}</div>
|
||
<span class="tree-arrow open" id="arr-${id}">▶</span>
|
||
<span class="tree-folder-icon">📁</span>
|
||
<span class="tree-folder-label">${escHtml(key)}</span>
|
||
<span class="tree-folder-count">${count}</span>
|
||
</div>
|
||
<div class="tree-children" id="${id}">${renderNode(child, depth + 1)}</div>
|
||
</div>`;
|
||
});
|
||
return html;
|
||
}
|
||
|
||
grid.innerHTML = `<div class="tree-root">${renderNode(tree, 0)}</div>`;
|
||
}
|
||
|
||
function toggleGroup(id) {
|
||
const body = document.getElementById(id);
|
||
const arr = document.getElementById('arr-' + id);
|
||
if (!body) return;
|
||
const collapsed = body.classList.toggle('collapsed');
|
||
if (arr) arr.classList.toggle('open', !collapsed);
|
||
}
|
||
|
||
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${uc.group ? ' · 📁 ' + escHtml(uc.group) : ''}</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 : '');
|
||
setVal('uc-group', uc ? uc.group || '' : '');
|
||
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);
|
||
// Populate datalist with existing group paths
|
||
const groups = [
|
||
...new Set(allUsecases.map((u) => u.group || '').filter(Boolean)),
|
||
];
|
||
document.getElementById('uc-group-list').innerHTML = groups
|
||
.map((g) => `<option value="${escHtml(g)}">`)
|
||
.join('');
|
||
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,
|
||
group: getVal('uc-group').trim(),
|
||
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>
|