Fix: use ws.send() directly for auth — send() expects a result response but HA responds to auth with auth_ok/auth_invalid (no id), causing 60s timeout loop
This commit is contained in:
+255
-349
@@ -5,139 +5,73 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Parental Controls</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b24;
|
||||
--surface2: #1e2535;
|
||||
--surface3: #252d3d;
|
||||
--border: #2a3347;
|
||||
--text: #dde3f0;
|
||||
--text-muted:#6b7a99;
|
||||
--accent: #6b8bef;
|
||||
--accent-h: #7e9cf5;
|
||||
--success: #3ecf8e;
|
||||
--danger: #f56565;
|
||||
--warning: #f6ad55;
|
||||
--r: 14px;
|
||||
--r-sm: 8px;
|
||||
--shadow: 0 8px 32px rgba(0,0,0,.55);
|
||||
--tr: .18s ease;
|
||||
}
|
||||
:root{--bg:#0d1117;--surface:#161b24;--surface2:#1e2535;--surface3:#252d3d;--border:#2a3347;--text:#dde3f0;--text-muted:#6b7a99;--accent:#6b8bef;--accent-h:#7e9cf5;--success:#3ecf8e;--danger:#f56565;--warning:#f6ad55;--r:14px;--r-sm:8px;--shadow:0 8px 32px rgba(0,0,0,.55);--tr:.18s ease}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);font-size:15px}
|
||||
::-webkit-scrollbar{width:6px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
#setup-screen{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:999;padding:20px}
|
||||
.setup-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:36px;width:min(440px,100%);box-shadow:var(--shadow)}
|
||||
.setup-logo{font-size:2.8rem;margin-bottom:12px;display:block}
|
||||
.setup-card h1{font-size:1.5rem;font-weight:700;margin-bottom:6px;letter-spacing:-.02em}
|
||||
.setup-card p{color:var(--text-muted);font-size:.875rem;line-height:1.6;margin-bottom:24px}
|
||||
header{background:var(--surface);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
|
||||
.h-left{display:flex;align-items:center;gap:10px}
|
||||
.h-left h1{font-size:1.15rem;font-weight:700;letter-spacing:-.02em}
|
||||
.logo-icon{font-size:1.4rem}
|
||||
.h-right{display:flex;align-items:center;gap:8px}
|
||||
.h-left{display:flex;align-items:center;gap:10px}.h-left h1{font-size:1.15rem;font-weight:700;letter-spacing:-.02em}.logo-icon{font-size:1.4rem}.h-right{display:flex;align-items:center;gap:8px}
|
||||
.badge{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;padding:3px 10px;border-radius:20px}
|
||||
.badge.connected {background:rgba(62,207,142,.12);color:var(--success);border:1px solid rgba(62,207,142,.3)}
|
||||
.badge.connecting {background:rgba(246,173,85,.12);color:var(--warning);border:1px solid rgba(246,173,85,.3)}
|
||||
.badge.connected{background:rgba(62,207,142,.12);color:var(--success);border:1px solid rgba(62,207,142,.3)}
|
||||
.badge.connecting{background:rgba(246,173,85,.12);color:var(--warning);border:1px solid rgba(246,173,85,.3)}
|
||||
.badge.disconnected{background:rgba(245,101,101,.12);color:var(--danger);border:1px solid rgba(245,101,101,.3)}
|
||||
.btn{padding:7px 16px;border-radius:var(--r-sm);border:none;cursor:pointer;font-size:.85rem;font-weight:600;transition:all var(--tr);white-space:nowrap;line-height:1.4}
|
||||
.btn-primary{background:var(--accent);color:#fff}
|
||||
.btn-primary:hover{background:var(--accent-h);transform:translateY(-1px)}
|
||||
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)}
|
||||
.btn-ghost:hover{background:var(--surface2);color:var(--text)}
|
||||
.btn-sm{padding:5px 12px;font-size:.78rem}
|
||||
.btn-full{width:100%;margin-top:6px;padding:10px}
|
||||
.icon-btn{background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--r-sm);width:34px;height:34px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:.95rem;transition:all var(--tr)}
|
||||
.icon-btn:hover{background:var(--surface2);color:var(--text)}
|
||||
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-h);transform:translateY(-1px)}
|
||||
.btn-ghost{background:transparent;color:var(--text-muted);border:1px solid var(--border)}.btn-ghost:hover{background:var(--surface2);color:var(--text)}
|
||||
.btn-sm{padding:5px 12px;font-size:.78rem}.btn-full{width:100%;margin-top:6px;padding:10px}
|
||||
.icon-btn{background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--r-sm);width:34px;height:34px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:.95rem;transition:all var(--tr)}.icon-btn:hover{background:var(--surface2);color:var(--text)}
|
||||
.tog-wrap{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
|
||||
.tog{position:relative;width:42px;height:23px;flex-shrink:0}
|
||||
.tog input{opacity:0;width:0;height:0;position:absolute}
|
||||
.tog{position:relative;width:42px;height:23px;flex-shrink:0}.tog input{opacity:0;width:0;height:0;position:absolute}
|
||||
.tog-track{position:absolute;inset:0;border-radius:12px;background:var(--border);transition:background var(--tr)}
|
||||
.tog-thumb{position:absolute;width:17px;height:17px;top:3px;left:3px;background:var(--text-muted);border-radius:50%;transition:transform var(--tr),background var(--tr)}
|
||||
.tog input:checked ~ .tog-track{background:var(--danger)}
|
||||
.tog input:checked ~ .tog-thumb{transform:translateX(19px);background:#fff}
|
||||
.tog.grn input:checked ~ .tog-track{background:var(--success)}
|
||||
.tog.sm{width:34px;height:19px}
|
||||
.tog.sm .tog-thumb{width:13px;height:13px}
|
||||
.tog.sm input:checked ~ .tog-thumb{transform:translateX(15px)}
|
||||
#app{display:none;height:100vh;flex-direction:column}
|
||||
#app.visible{display:flex}
|
||||
main{flex:1;overflow-y:auto;padding:24px}
|
||||
.tog input:checked~.tog-track{background:var(--danger)}.tog input:checked~.tog-thumb{transform:translateX(19px);background:#fff}
|
||||
.tog.grn input:checked~.tog-track{background:var(--success)}.tog.sm{width:34px;height:19px}.tog.sm .tog-thumb{width:13px;height:13px}.tog.sm input:checked~.tog-thumb{transform:translateX(15px)}
|
||||
#app{display:none;height:100vh;flex-direction:column}#app.visible{display:flex}main{flex:1;overflow-y:auto;padding:24px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:18px;align-items:start}
|
||||
.user-card{background:var(--surface);border-radius:var(--r);border:1px solid var(--border);overflow:hidden;transition:box-shadow var(--tr),border-color var(--tr)}
|
||||
.user-card.blocked{border-color:rgba(245,101,101,.4)}
|
||||
.user-card:hover{box-shadow:var(--shadow)}
|
||||
.user-card{background:var(--surface);border-radius:var(--r);border:1px solid var(--border);overflow:hidden;transition:box-shadow var(--tr),border-color var(--tr)}.user-card.blocked{border-color:rgba(245,101,101,.4)}.user-card:hover{box-shadow:var(--shadow)}
|
||||
.card-head{padding:14px 18px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--border);background:var(--surface2)}
|
||||
.color-dot{width:12px;height:12px;border-radius:50%;flex-shrink:0}
|
||||
.user-name-el{flex:1;font-weight:700;font-size:1rem;letter-spacing:-.01em}
|
||||
.status-lbl{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}
|
||||
.status-lbl.blocked{color:var(--danger)}
|
||||
.del-btn{background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:1rem;padding:2px 6px;border-radius:4px;transition:all var(--tr);line-height:1}
|
||||
.del-btn:hover{color:var(--danger);background:rgba(245,101,101,.1)}
|
||||
.color-dot{width:12px;height:12px;border-radius:50%;flex-shrink:0}.user-name-el{flex:1;font-weight:700;font-size:1rem;letter-spacing:-.01em}
|
||||
.status-lbl{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}.status-lbl.blocked{color:var(--danger)}
|
||||
.del-btn{background:transparent;border:none;color:var(--text-muted);cursor:pointer;font-size:1rem;padding:2px 6px;border-radius:4px;transition:all var(--tr);line-height:1}.del-btn:hover{color:var(--danger);background:rgba(245,101,101,.1)}
|
||||
.card-body{padding:16px 18px}
|
||||
.sec-title{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:10px;display:flex;justify-content:space-between;align-items:center}
|
||||
.dev-list{display:flex;flex-direction:column;gap:7px;margin-bottom:12px}
|
||||
.dev-item{background:var(--surface2);border-radius:var(--r-sm);padding:9px 12px;display:flex;align-items:center;gap:9px;border:1px solid transparent;transition:border-color var(--tr)}
|
||||
.dev-item.blocked{border-color:rgba(245,101,101,.3)}
|
||||
.online-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:var(--text-muted)}
|
||||
.online-dot.on{background:var(--success);box-shadow:0 0 5px var(--success)}
|
||||
.dev-info{flex:1;min-width:0}
|
||||
.dev-name{font-size:.875rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.dev-item{background:var(--surface2);border-radius:var(--r-sm);padding:9px 12px;display:flex;align-items:center;gap:9px;border:1px solid transparent;transition:border-color var(--tr)}.dev-item.blocked{border-color:rgba(245,101,101,.3)}
|
||||
.online-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:var(--text-muted)}.online-dot.on{background:var(--success);box-shadow:0 0 5px var(--success)}
|
||||
.dev-info{flex:1;min-width:0}.dev-name{font-size:.875rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.dev-sub{font-size:.7rem;color:var(--text-muted);font-family:monospace;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.dev-acts{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||
.no-dev{color:var(--text-muted);font-size:.85rem;text-align:center;padding:10px 0}
|
||||
.dev-acts{display:flex;align-items:center;gap:6px;flex-shrink:0}.no-dev{color:var(--text-muted);font-size:.85rem;text-align:center;padding:10px 0}
|
||||
.sched-section{border-top:1px solid var(--border);padding-top:14px;margin-top:14px}
|
||||
.sched-head{display:flex;align-items:center;justify-content:space-between;cursor:pointer;margin-bottom:0}
|
||||
.sched-title{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted)}
|
||||
.sched-chev{color:var(--text-muted);transition:transform var(--tr);font-size:.7rem;line-height:1}
|
||||
.sched-chev.open{transform:rotate(180deg)}
|
||||
.sched-body{display:none;margin-top:12px}
|
||||
.sched-body.open{display:block}
|
||||
.sched-chev{color:var(--text-muted);transition:transform var(--tr);font-size:.7rem;line-height:1}.sched-chev.open{transform:rotate(180deg)}
|
||||
.sched-body{display:none;margin-top:12px}.sched-body.open{display:block}
|
||||
.sched-en-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.sched-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
|
||||
.sched-row label{font-size:.78rem;color:var(--text-muted);width:68px;flex-shrink:0}
|
||||
.time-in{background:var(--surface3);border:1px solid var(--border);color:var(--text);border-radius:var(--r-sm);padding:5px 8px;font-size:.82rem;width:88px;transition:border-color var(--tr)}
|
||||
.time-in:focus{outline:none;border-color:var(--accent)}
|
||||
.time-sep{color:var(--text-muted);font-size:.78rem}
|
||||
.sched-hint{font-size:.72rem;color:var(--text-muted);margin-top:4px;line-height:1.4}
|
||||
.add-user-tile{background:var(--surface);border-radius:var(--r);border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;min-height:200px;cursor:pointer;transition:all var(--tr)}
|
||||
.add-user-tile:hover{border-color:var(--accent);background:rgba(107,139,239,.05)}
|
||||
.add-user-tile-inner{text-align:center;color:var(--text-muted)}
|
||||
.add-user-tile-inner .plus{font-size:2rem;margin-bottom:6px;line-height:1}
|
||||
.add-user-tile-inner p{font-size:.85rem}
|
||||
.empty{text-align:center;padding:64px 20px;grid-column:1/-1;color:var(--text-muted)}
|
||||
.empty .big{font-size:3rem;margin-bottom:14px}
|
||||
.empty h2{font-size:1.1rem;color:var(--text);margin-bottom:6px}
|
||||
.empty p{font-size:.875rem}
|
||||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:200;display:flex;align-items:center;justify-content:center;padding:20px;backdrop-filter:blur(4px);opacity:0;pointer-events:none;transition:opacity var(--tr)}
|
||||
.overlay.open{opacity:1;pointer-events:all}
|
||||
.modal{background:var(--surface);border-radius:var(--r);border:1px solid var(--border);padding:26px;width:min(460px,100%);box-shadow:var(--shadow);transform:translateY(16px);transition:transform var(--tr)}
|
||||
.overlay.open .modal{transform:translateY(0)}
|
||||
.modal h2{font-size:1.1rem;font-weight:700;margin-bottom:18px;letter-spacing:-.01em}
|
||||
.fg{margin-bottom:14px}
|
||||
.sched-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}.sched-row label{font-size:.78rem;color:var(--text-muted);width:68px;flex-shrink:0}
|
||||
.time-in{background:var(--surface3);border:1px solid var(--border);color:var(--text);border-radius:var(--r-sm);padding:5px 8px;font-size:.82rem;width:88px;transition:border-color var(--tr)}.time-in:focus{outline:none;border-color:var(--accent)}
|
||||
.time-sep{color:var(--text-muted);font-size:.78rem}.sched-hint{font-size:.72rem;color:var(--text-muted);margin-top:4px;line-height:1.4}
|
||||
.add-user-tile{background:var(--surface);border-radius:var(--r);border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;min-height:200px;cursor:pointer;transition:all var(--tr)}.add-user-tile:hover{border-color:var(--accent);background:rgba(107,139,239,.05)}
|
||||
.add-user-tile-inner{text-align:center;color:var(--text-muted)}.add-user-tile-inner .plus{font-size:2rem;margin-bottom:6px;line-height:1}.add-user-tile-inner p{font-size:.85rem}
|
||||
.empty{text-align:center;padding:64px 20px;grid-column:1/-1;color:var(--text-muted)}.empty .big{font-size:3rem;margin-bottom:14px}.empty h2{font-size:1.1rem;color:var(--text);margin-bottom:6px}.empty p{font-size:.875rem}
|
||||
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:200;display:flex;align-items:center;justify-content:center;padding:20px;backdrop-filter:blur(4px);opacity:0;pointer-events:none;transition:opacity var(--tr)}.overlay.open{opacity:1;pointer-events:all}
|
||||
.modal{background:var(--surface);border-radius:var(--r);border:1px solid var(--border);padding:26px;width:min(460px,100%);box-shadow:var(--shadow);transform:translateY(16px);transition:transform var(--tr)}.overlay.open .modal{transform:translateY(0)}
|
||||
.modal h2{font-size:1.1rem;font-weight:700;margin-bottom:18px;letter-spacing:-.01em}.fg{margin-bottom:14px}
|
||||
.fg label{display:block;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:5px}
|
||||
.fi{width:100%;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:var(--r-sm);padding:9px 12px;font-size:.875rem;transition:border-color var(--tr)}
|
||||
.fi:focus{outline:none;border-color:var(--accent)}
|
||||
.fi{width:100%;background:var(--surface2);border:1px solid var(--border);color:var(--text);border-radius:var(--r-sm);padding:9px 12px;font-size:.875rem;transition:border-color var(--tr)}.fi:focus{outline:none;border-color:var(--accent)}
|
||||
.modal-acts{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
|
||||
.col-row{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.col-opt{width:26px;height:26px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:all var(--tr)}
|
||||
.col-opt.sel{border-color:#fff;transform:scale(1.18)}
|
||||
.col-row{display:flex;gap:8px;flex-wrap:wrap}.col-opt{width:26px;height:26px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:all var(--tr)}.col-opt.sel{border-color:#fff;transform:scale(1.18)}
|
||||
.disc-list{max-height:210px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--r-sm);margin-top:6px;background:var(--surface2)}
|
||||
.disc-item{padding:9px 12px;cursor:pointer;transition:background var(--tr);display:flex;align-items:center;gap:9px;border-bottom:1px solid var(--border)}
|
||||
.disc-item:last-child{border-bottom:none}
|
||||
.disc-item:hover{background:var(--surface3)}
|
||||
.disc-item.sel{background:rgba(107,139,239,.18)}
|
||||
.disc-name{font-size:.875rem;font-weight:600}
|
||||
.disc-sub{font-size:.72rem;color:var(--text-muted);font-family:monospace;margin-top:1px}
|
||||
.disc-item{padding:9px 12px;cursor:pointer;transition:background var(--tr);display:flex;align-items:center;gap:9px;border-bottom:1px solid var(--border)}.disc-item:last-child{border-bottom:none}.disc-item:hover{background:var(--surface3)}.disc-item.sel{background:rgba(107,139,239,.18)}
|
||||
.disc-name{font-size:.875rem;font-weight:600}.disc-sub{font-size:.72rem;color:var(--text-muted);font-family:monospace;margin-top:1px}
|
||||
#toasts{position:fixed;bottom:22px;right:22px;z-index:400;display:flex;flex-direction:column;gap:7px;pointer-events:none}
|
||||
.toast{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-sm);padding:11px 16px;font-size:.85rem;font-weight:500;box-shadow:var(--shadow);max-width:300px;transition:opacity .3s;border-left:3px solid var(--border)}
|
||||
.toast.s{border-left-color:var(--success)}
|
||||
.toast.e{border-left-color:var(--danger)}
|
||||
.toast.w{border-left-color:var(--warning)}
|
||||
@keyframes ti{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
.toast{animation:ti .25s ease}
|
||||
.toast.s{border-left-color:var(--success)}.toast.e{border-left-color:var(--danger)}.toast.w{border-left-color:var(--warning)}
|
||||
@keyframes ti{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}.toast{animation:ti .25s ease}
|
||||
input[type=time]::-webkit-calendar-picker-indicator{filter:invert(.5)}
|
||||
@media(max-width:600px){header{padding:12px 16px}main{padding:14px}.grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -148,8 +82,8 @@ input[type=time]::-webkit-calendar-picker-indicator{filter:invert(.5)}
|
||||
<span class="setup-logo">🛡️</span>
|
||||
<h1>Parental Controls</h1>
|
||||
<p>Connect to your Home Assistant instance. You'll need a <strong>Long-Lived Access Token</strong> from your HA profile page (Profile → Security → Long-Lived Access Tokens).</p>
|
||||
<div class="fg"><label>Home Assistant URL</label><input id="s-url" class="fi" type="text" placeholder="https://ha.example.com" autocomplete="off" /></div>
|
||||
<div class="fg"><label>Long-Lived Access Token</label><input id="s-tok" class="fi" type="password" placeholder="eyJ..." autocomplete="off" /></div>
|
||||
<div class="fg"><label>Home Assistant URL</label><input id="s-url" class="fi" type="text" placeholder="https://ha.example.com" autocomplete="off"/></div>
|
||||
<div class="fg"><label>Long-Lived Access Token</label><input id="s-tok" class="fi" type="password" placeholder="eyJ..." autocomplete="off"/></div>
|
||||
<button class="btn btn-primary btn-full" onclick="doSetup()">Connect →</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,309 +100,281 @@ input[type=time]::-webkit-calendar-picker-indicator{filter:invert(.5)}
|
||||
</div>
|
||||
<div id="toasts"></div>
|
||||
<div id="add-user-modal" class="overlay" onclick="overlayClick(event,'add-user-modal')">
|
||||
<div class="modal">
|
||||
<h2>Add User</h2>
|
||||
<div class="fg"><label>Name</label><input id="u-name" class="fi" type="text" placeholder="e.g. Alex" /></div>
|
||||
<div class="modal"><h2>Add User</h2>
|
||||
<div class="fg"><label>Name</label><input id="u-name" class="fi" type="text" placeholder="e.g. Alex"/></div>
|
||||
<div class="fg"><label>Colour</label><div class="col-row" id="col-picker"></div></div>
|
||||
<div class="modal-acts"><button class="btn btn-ghost" onclick="closeModal('add-user-modal')">Cancel</button><button class="btn btn-primary" onclick="createUser()">Create User</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="add-dev-modal" class="overlay" onclick="overlayClick(event,'add-dev-modal')">
|
||||
<div class="modal">
|
||||
<h2>Add Device</h2>
|
||||
<div class="fg"><label>Device Name</label><input id="d-name" class="fi" type="text" placeholder="e.g. Alex's iPad" /></div>
|
||||
<div class="fg"><label>MAC Address</label><input id="d-mac" class="fi" type="text" placeholder="aa:bb:cc:dd:ee:ff" autocomplete="off" /></div>
|
||||
<div class="modal"><h2>Add Device</h2>
|
||||
<div class="fg"><label>Device Name</label><input id="d-name" class="fi" type="text" placeholder="e.g. Alex's iPad"/></div>
|
||||
<div class="fg"><label>MAC Address</label><input id="d-mac" class="fi" type="text" placeholder="aa:bb:cc:dd:ee:ff" autocomplete="off"/></div>
|
||||
<div class="fg"><label>Or pick a discovered device</label><div id="disc-list" class="disc-list"></div></div>
|
||||
<div class="modal-acts"><button class="btn btn-ghost" onclick="closeModal('add-dev-modal')">Cancel</button><button class="btn btn-primary" onclick="addDevice()">Add Device</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-modal" class="overlay" onclick="overlayClick(event,'settings-modal')">
|
||||
<div class="modal">
|
||||
<h2>⚙ Settings</h2>
|
||||
<div class="fg"><label>Home Assistant URL</label><input id="set-url" class="fi" type="text" /></div>
|
||||
<div class="fg"><label>Long-Lived Access Token</label><input id="set-tok" class="fi" type="password" /></div>
|
||||
<div class="modal"><h2>⚙ Settings</h2>
|
||||
<div class="fg"><label>Home Assistant URL</label><input id="set-url" class="fi" type="text"/></div>
|
||||
<div class="fg"><label>Long-Lived Access Token</label><input id="set-tok" class="fi" type="password"/></div>
|
||||
<div class="modal-acts"><button class="btn btn-ghost" onclick="closeModal('settings-modal')">Cancel</button><button class="btn btn-primary" onclick="saveSettings()">Save & Reconnect</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const CFG_ENTITY = 'input_text.parental_control_config';
|
||||
const COLORS = ['#ef5350','#ff7043','#ffa726','#66bb6a','#26c6da','#42a5f5','#7e57c2','#ec407a','#26a69a','#e6c229'];
|
||||
const MAC_RE = /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i;
|
||||
let ws, wsId = 1, pending = {}, retryTimer;
|
||||
let states = {}, cfg = { users: [] }, prefs = { url: '', token: '' };
|
||||
let devModalUserId = null, schedOpen = {}, renderTimer = null;
|
||||
const CFG_ENTITY='input_text.parental_control_config';
|
||||
const COLORS=['#ef5350','#ff7043','#ffa726','#66bb6a','#26c6da','#42a5f5','#7e57c2','#ec407a','#26a69a','#e6c229'];
|
||||
const MAC_RE=/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i;
|
||||
let ws,wsId=1,pending={},retryTimer;
|
||||
let states={},cfg={users:[]},prefs={url:'',token:''};
|
||||
let devModalUserId=null,schedOpen={},renderTimer=null;
|
||||
|
||||
function loadPrefs() {
|
||||
try { const p = localStorage.getItem('pc_prefs'); if (p) { prefs = JSON.parse(p); return true; } } catch(e) {}
|
||||
return false;
|
||||
}
|
||||
function persistPrefs() { localStorage.setItem('pc_prefs', JSON.stringify(prefs)); }
|
||||
function doSetup() {
|
||||
const url = document.getElementById('s-url').value.trim().replace(/\/+$/,'');
|
||||
const tok = document.getElementById('s-tok').value.trim();
|
||||
if (!url || !tok) { toast('Fill in both fields', 'e'); return; }
|
||||
prefs = { url, token: tok }; persistPrefs();
|
||||
document.getElementById('setup-screen').style.display = 'none';
|
||||
function loadPrefs(){try{const p=localStorage.getItem('pc_prefs');if(p){prefs=JSON.parse(p);return true;}}catch(e){}return false;}
|
||||
function persistPrefs(){localStorage.setItem('pc_prefs',JSON.stringify(prefs));}
|
||||
function doSetup(){
|
||||
const url=document.getElementById('s-url').value.trim().replace(/\/+$/,'');
|
||||
const tok=document.getElementById('s-tok').value.trim();
|
||||
if(!url||!tok){toast('Fill in both fields','e');return;}
|
||||
prefs={url,token:tok};persistPrefs();
|
||||
document.getElementById('setup-screen').style.display='none';
|
||||
document.getElementById('app').classList.add('visible');
|
||||
connect();
|
||||
}
|
||||
function saveSettings() {
|
||||
const url = document.getElementById('set-url').value.trim().replace(/\/+$/,'');
|
||||
const tok = document.getElementById('set-tok').value.trim();
|
||||
if (!url || !tok) { toast('Fill in both fields', 'e'); return; }
|
||||
prefs = { url, token: tok }; persistPrefs();
|
||||
closeModal('settings-modal'); if (ws) ws.close(); connect();
|
||||
function saveSettings(){
|
||||
const url=document.getElementById('set-url').value.trim().replace(/\/+$/,'');
|
||||
const tok=document.getElementById('set-tok').value.trim();
|
||||
if(!url||!tok){toast('Fill in both fields','e');return;}
|
||||
prefs={url,token:tok};persistPrefs();
|
||||
closeModal('settings-modal');if(ws)ws.close();connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
setBadge('connecting'); clearTimeout(retryTimer);
|
||||
const wsUrl = prefs.url.replace(/^https?/,m => m==='https'?'wss':'ws') + '/api/websocket';
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onmessage = e => dispatch(JSON.parse(e.data));
|
||||
ws.onerror = () => {};
|
||||
ws.onclose = () => { setBadge('disconnected'); retryTimer = setTimeout(connect, 6000); };
|
||||
} catch(ex) { setBadge('disconnected'); retryTimer = setTimeout(connect, 6000); }
|
||||
function connect(){
|
||||
setBadge('connecting');clearTimeout(retryTimer);
|
||||
const wsUrl=prefs.url.replace(/^https?/,m=>m==='https'?'wss':'ws')+'/api/websocket';
|
||||
try{
|
||||
ws=new WebSocket(wsUrl);
|
||||
ws.onmessage=e=>dispatch(JSON.parse(e.data));
|
||||
ws.onerror=()=>{};
|
||||
ws.onclose=()=>{setBadge('disconnected');retryTimer=setTimeout(connect,6000);};
|
||||
}catch(ex){setBadge('disconnected');retryTimer=setTimeout(connect,6000);}
|
||||
}
|
||||
|
||||
function dispatch(msg) {
|
||||
switch (msg.type) {
|
||||
case 'auth_required': send({ type:'auth', access_token: prefs.token }); break;
|
||||
function dispatch(msg){
|
||||
switch(msg.type){
|
||||
// Auth uses ws.send directly — HA responds with auth_ok/auth_invalid (no id field),
|
||||
// so using send() would create an unresolved promise that times out after 60s
|
||||
case 'auth_required':
|
||||
ws.send(JSON.stringify({type:'auth',access_token:prefs.token}));
|
||||
break;
|
||||
case 'auth_ok': onAuth(); break;
|
||||
case 'auth_invalid': setBadge('disconnected'); toast('HA auth failed — check your token', 'e'); break;
|
||||
case 'auth_invalid': setBadge('disconnected'); toast('HA auth failed — check your token','e'); break;
|
||||
case 'result':
|
||||
if (pending[msg.id]) {
|
||||
const { res, rej } = pending[msg.id]; delete pending[msg.id];
|
||||
msg.success ? res(msg) : rej(new Error(msg.error?.message || 'HA error'));
|
||||
if(pending[msg.id]){
|
||||
const{res,rej}=pending[msg.id];delete pending[msg.id];
|
||||
msg.success?res(msg):rej(new Error(msg.error?.message||'HA error'));
|
||||
}
|
||||
break;
|
||||
case 'event': if (msg.event?.event_type === 'state_changed') onStateChange(msg.event.data); break;
|
||||
case 'event': if(msg.event?.event_type==='state_changed')onStateChange(msg.event.data); break;
|
||||
}
|
||||
}
|
||||
function send(msg) {
|
||||
return new Promise((res, rej) => {
|
||||
const id = wsId++; msg.id = id; pending[id] = { res, rej };
|
||||
setTimeout(() => { if (pending[id]) { delete pending[id]; rej(new Error('timeout')); } }, 60000);
|
||||
|
||||
function send(msg){
|
||||
return new Promise((res,rej)=>{
|
||||
const id=wsId++;msg.id=id;pending[id]={res,rej};
|
||||
setTimeout(()=>{if(pending[id]){delete pending[id];rej(new Error('timeout'));}},60000);
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
}
|
||||
|
||||
async function onAuth() {
|
||||
async function onAuth(){
|
||||
setBadge('connected');
|
||||
// Use REST API for initial state load — handles large HA instances better
|
||||
// than WebSocket get_states which can timeout with hundreds of entities
|
||||
try {
|
||||
const resp = await fetch(`${prefs.url}/api/states`, {
|
||||
headers: { Authorization: `Bearer ${prefs.token}` }
|
||||
});
|
||||
if (resp.ok) { const all = await resp.json(); all.forEach(s => { states[s.entity_id] = s; }); }
|
||||
} catch(e) {
|
||||
try { const r = await send({ type: 'get_states' }); r.result.forEach(s => { states[s.entity_id] = s; }); }
|
||||
catch(e2) { console.warn('Could not load initial states:', e2); }
|
||||
// Use REST API for initial state load — much more reliable for large HA instances
|
||||
try{
|
||||
const resp=await fetch(`${prefs.url}/api/states`,{headers:{Authorization:`Bearer ${prefs.token}`}});
|
||||
if(resp.ok){const all=await resp.json();all.forEach(s=>{states[s.entity_id]=s;});}
|
||||
}catch(e){
|
||||
try{const r=await send({type:'get_states'});r.result.forEach(s=>{states[s.entity_id]=s;});}
|
||||
catch(e2){console.warn('Could not load states:',e2);}
|
||||
}
|
||||
try { await send({ type: 'subscribe_events', event_type: 'state_changed' }); }
|
||||
catch(e) { console.warn('Could not subscribe to events:', e); }
|
||||
try{await send({type:'subscribe_events',event_type:'state_changed'});}
|
||||
catch(e){console.warn('Could not subscribe:',e);}
|
||||
parseCfg(states[CFG_ENTITY]?.state);
|
||||
render();
|
||||
}
|
||||
|
||||
function onStateChange({ entity_id, new_state }) {
|
||||
states[entity_id] = new_state;
|
||||
if (entity_id === CFG_ENTITY) { parseCfg(new_state?.state); schedRender(); return; }
|
||||
if (entity_id.startsWith('device_tracker.')) {
|
||||
if (new_state?.state === 'home') handleReconnect(new_state);
|
||||
function onStateChange({entity_id,new_state}){
|
||||
states[entity_id]=new_state;
|
||||
if(entity_id===CFG_ENTITY){parseCfg(new_state?.state);schedRender();return;}
|
||||
if(entity_id.startsWith('device_tracker.')){
|
||||
if(new_state?.state==='home')handleReconnect(new_state);
|
||||
schedRender();
|
||||
}
|
||||
}
|
||||
async function handleReconnect(t) {
|
||||
const mac = normMac(t.attributes?.mac || t.attributes?.mac_address || '');
|
||||
const newIp = t.attributes?.ip || t.attributes?.ip_address || '';
|
||||
if (!mac || !newIp) return;
|
||||
for (const user of cfg.users) {
|
||||
for (const dev of user.devices) {
|
||||
if (normMac(dev.mac) === mac && dev.blocked) {
|
||||
dev.last_ip = newIp; await callBlock(newIp); await callApply();
|
||||
toast(`Re-blocked ${dev.name} (reconnected)`, 'w'); await saveCfg();
|
||||
}
|
||||
async function handleReconnect(t){
|
||||
const mac=normMac(t.attributes?.mac||t.attributes?.mac_address||'');
|
||||
const newIp=t.attributes?.ip||t.attributes?.ip_address||'';
|
||||
if(!mac||!newIp)return;
|
||||
for(const user of cfg.users)for(const dev of user.devices)
|
||||
if(normMac(dev.mac)===mac&&dev.blocked){dev.last_ip=newIp;await callBlock(newIp);await callApply();toast(`Re-blocked ${dev.name}(reconnected)`,'w');await saveCfg();}
|
||||
}
|
||||
function parseCfg(val){
|
||||
try{if(val&&val!=='unknown'&&val!=='unavailable'&&val.trim()[0]==='{'){cfg=JSON.parse(val);if(!cfg.users)cfg.users=[];}}catch(e){}
|
||||
}
|
||||
async function saveCfg(){
|
||||
const json=JSON.stringify(cfg);
|
||||
if(json.length>9500)toast('Config nearing size limit','w');
|
||||
try{await send({type:'call_service',domain:'input_text',service:'set_value',service_data:{entity_id:CFG_ENTITY,value:json}});}
|
||||
catch(e){toast('Failed to save config','e');}
|
||||
}
|
||||
|
||||
function normMac(m){return m.toLowerCase().replace(/-/g,':');}
|
||||
function deviceInfo(mac){
|
||||
const m=normMac(mac);
|
||||
for(const[eid,s]of Object.entries(states)){
|
||||
if(!eid.startsWith('device_tracker.'))continue;
|
||||
const a=s.attributes||{};
|
||||
const sm=normMac(a.mac||a.mac_address||a.macaddress||'');
|
||||
if(sm===m)return{online:s.state==='home',ip:a.ip||a.ip_address||'',label:a.friendly_name||a.hostname||eid.replace('device_tracker.',''),eid};
|
||||
}
|
||||
return{online:false,ip:'',label:'',eid:null};
|
||||
}
|
||||
function discoveredDevices(){
|
||||
const assigned=new Set(cfg.users.flatMap(u=>u.devices.map(d=>normMac(d.mac))));
|
||||
const out=[];
|
||||
for(const[eid,s]of Object.entries(states)){
|
||||
if(!eid.startsWith('device_tracker.'))continue;
|
||||
const a=s.attributes||{};const mac=normMac(a.mac||a.mac_address||a.macaddress||'');
|
||||
if(!mac||assigned.has(mac))continue;
|
||||
out.push({mac,eid,name:a.friendly_name||a.hostname||eid.replace('device_tracker.',''),ip:a.ip||a.ip_address||'',online:s.state==='home'});
|
||||
}
|
||||
return out.sort((a,b)=>b.online-a.online);
|
||||
}
|
||||
|
||||
async function callBlock(ip){await send({type:'call_service',domain:'script',service:'parental_block_ip',service_data:{ip}});}
|
||||
async function callUnblock(ip){await send({type:'call_service',domain:'script',service:'parental_unblock_ip',service_data:{ip}});}
|
||||
async function callApply(){try{await send({type:'call_service',domain:'script',service:'parental_apply_firewall',service_data:{}});}catch(e){}}
|
||||
|
||||
async function toggleUser(userId){
|
||||
const user=cfg.users.find(u=>u.id===userId);if(!user)return;
|
||||
const block=!user.blocked;user.blocked=block;let ok=0,fail=0;
|
||||
for(const dev of user.devices){
|
||||
const info=deviceInfo(dev.mac);const ip=info.ip||dev.last_ip;
|
||||
if(ip){try{block?await callBlock(ip):await callUnblock(ip);dev.blocked=block;if(info.ip)dev.last_ip=info.ip;ok++;}catch(e){fail++;}}
|
||||
else{dev.blocked=block;if(block)toast(`${dev.name}: no IP yet — will block when it connects`,'w');}
|
||||
}
|
||||
await callApply();await saveCfg();render();
|
||||
if(fail===0)toast(`${user.name} ${block?'blocked 🔒':'unblocked ✓'}`,block?'w':'s');
|
||||
else toast(`${ok} ok, ${fail} failed`,'e');
|
||||
}
|
||||
async function toggleDevice(userId,mac){
|
||||
const user=cfg.users.find(u=>u.id===userId);const dev=user?.devices.find(d=>d.mac===mac);if(!dev)return;
|
||||
const block=!dev.blocked;const info=deviceInfo(mac);const ip=info.ip||dev.last_ip;
|
||||
if(!ip){dev.blocked=block;await saveCfg();render();toast(block?`${dev.name}: no IP yet — will apply when online`:`${dev.name} marked unblocked`,block?'w':'s');return;}
|
||||
try{
|
||||
block?await callBlock(ip):await callUnblock(ip);dev.blocked=block;if(info.ip)dev.last_ip=info.ip;
|
||||
await callApply();await saveCfg();render();toast(`${dev.name} ${block?'blocked 🔒':'unblocked ✓'}`,block?'w':'s');
|
||||
}catch(e){toast(`Failed to ${block?'block':'unblock'} ${dev.name}`,'e');}
|
||||
}
|
||||
|
||||
setInterval(runSchedules,60000);
|
||||
async function runSchedules(){
|
||||
const now=new Date();const hhmm=pad2(now.getHours())+':'+pad2(now.getMinutes());
|
||||
const weekend=now.getDay()===0||now.getDay()===6;let changed=false;
|
||||
for(const user of cfg.users){
|
||||
if(!user.schedule?.enabled)continue;
|
||||
const slot=weekend?user.schedule.weekend:user.schedule.weekday;
|
||||
const shouldBlock=inRange(hhmm,slot.block_time,slot.unblock_time);
|
||||
if(shouldBlock===user.blocked)continue;
|
||||
user.blocked=shouldBlock;
|
||||
for(const dev of user.devices){
|
||||
const info=deviceInfo(dev.mac);const ip=info.ip||dev.last_ip;
|
||||
if(ip){try{shouldBlock?await callBlock(ip):await callUnblock(ip);dev.blocked=shouldBlock;if(info.ip)dev.last_ip=info.ip;}catch(e){}}
|
||||
else{dev.blocked=shouldBlock;}
|
||||
}
|
||||
toast(`${user.name} ${shouldBlock?'blocked by schedule 🕐':'unblocked by schedule ✓'}`,shouldBlock?'w':'s');changed=true;
|
||||
}
|
||||
if(changed){await callApply();await saveCfg();render();}
|
||||
}
|
||||
function parseCfg(val) {
|
||||
try {
|
||||
if (val && val !== 'unknown' && val !== 'unavailable' && val.trim()[0] === '{') {
|
||||
cfg = JSON.parse(val); if (!cfg.users) cfg.users = [];
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
async function saveCfg() {
|
||||
const json = JSON.stringify(cfg);
|
||||
if (json.length > 9500) toast('Config nearing size limit', 'w');
|
||||
try { await send({ type:'call_service', domain:'input_text', service:'set_value', service_data:{ entity_id: CFG_ENTITY, value: json } }); }
|
||||
catch(e) { toast('Failed to save config', 'e'); }
|
||||
}
|
||||
|
||||
function normMac(m) { return m.toLowerCase().replace(/-/g,':'); }
|
||||
function deviceInfo(mac) {
|
||||
const m = normMac(mac);
|
||||
for (const [eid, s] of Object.entries(states)) {
|
||||
if (!eid.startsWith('device_tracker.')) continue;
|
||||
const a = s.attributes || {};
|
||||
const sm = normMac(a.mac || a.mac_address || a.macaddress || '');
|
||||
if (sm === m) return { online: s.state === 'home', ip: a.ip || a.ip_address || '', label: a.friendly_name || a.hostname || eid.replace('device_tracker.',''), eid };
|
||||
}
|
||||
return { online: false, ip: '', label: '', eid: null };
|
||||
}
|
||||
function discoveredDevices() {
|
||||
const assigned = new Set(cfg.users.flatMap(u => u.devices.map(d => normMac(d.mac))));
|
||||
const out = [];
|
||||
for (const [eid, s] of Object.entries(states)) {
|
||||
if (!eid.startsWith('device_tracker.')) continue;
|
||||
const a = s.attributes || {};
|
||||
const mac = normMac(a.mac || a.mac_address || a.macaddress || '');
|
||||
if (!mac || assigned.has(mac)) continue;
|
||||
out.push({ mac, eid, name: a.friendly_name || a.hostname || eid.replace('device_tracker.',''), ip: a.ip || a.ip_address || '', online: s.state === 'home' });
|
||||
}
|
||||
return out.sort((a,b) => b.online - a.online);
|
||||
}
|
||||
|
||||
async function callBlock(ip) { await send({ type:'call_service', domain:'script', service:'parental_block_ip', service_data:{ ip } }); }
|
||||
async function callUnblock(ip) { await send({ type:'call_service', domain:'script', service:'parental_unblock_ip', service_data:{ ip } }); }
|
||||
async function callApply() { try { await send({ type:'call_service', domain:'script', service:'parental_apply_firewall', service_data:{} }); } catch(e) {} }
|
||||
|
||||
async function toggleUser(userId) {
|
||||
const user = cfg.users.find(u => u.id === userId); if (!user) return;
|
||||
const block = !user.blocked; user.blocked = block; let ok = 0, fail = 0;
|
||||
for (const dev of user.devices) {
|
||||
const info = deviceInfo(dev.mac); const ip = info.ip || dev.last_ip;
|
||||
if (ip) {
|
||||
try { block ? await callBlock(ip) : await callUnblock(ip); dev.blocked = block; if (info.ip) dev.last_ip = info.ip; ok++; }
|
||||
catch(e) { fail++; }
|
||||
} else { dev.blocked = block; if (block) toast(`${dev.name}: no IP yet — will block when it connects`, 'w'); }
|
||||
}
|
||||
await callApply(); await saveCfg(); render();
|
||||
if (fail === 0) toast(`${user.name} ${block ? 'blocked 🔒' : 'unblocked ✓'}`, block ? 'w' : 's');
|
||||
else toast(`${ok} ok, ${fail} failed`, 'e');
|
||||
}
|
||||
async function toggleDevice(userId, mac) {
|
||||
const user = cfg.users.find(u => u.id === userId);
|
||||
const dev = user?.devices.find(d => d.mac === mac); if (!dev) return;
|
||||
const block = !dev.blocked; const info = deviceInfo(mac); const ip = info.ip || dev.last_ip;
|
||||
if (!ip) { dev.blocked = block; await saveCfg(); render(); toast(block ? `${dev.name}: no IP yet — will apply when online` : `${dev.name} marked unblocked`, block?'w':'s'); return; }
|
||||
try {
|
||||
block ? await callBlock(ip) : await callUnblock(ip); dev.blocked = block;
|
||||
if (info.ip) dev.last_ip = info.ip; await callApply(); await saveCfg(); render();
|
||||
toast(`${dev.name} ${block ? 'blocked 🔒' : 'unblocked ✓'}`, block ? 'w' : 's');
|
||||
} catch(e) { toast(`Failed to ${block?'block':'unblock'} ${dev.name}`, 'e'); }
|
||||
}
|
||||
|
||||
setInterval(runSchedules, 60000);
|
||||
async function runSchedules() {
|
||||
const now = new Date(); const hhmm = pad2(now.getHours()) + ':' + pad2(now.getMinutes());
|
||||
const weekend = now.getDay() === 0 || now.getDay() === 6; let changed = false;
|
||||
for (const user of cfg.users) {
|
||||
if (!user.schedule?.enabled) continue;
|
||||
const slot = weekend ? user.schedule.weekend : user.schedule.weekday;
|
||||
const shouldBlock = inRange(hhmm, slot.block_time, slot.unblock_time);
|
||||
if (shouldBlock === user.blocked) continue;
|
||||
user.blocked = shouldBlock;
|
||||
for (const dev of user.devices) {
|
||||
const info = deviceInfo(dev.mac); const ip = info.ip || dev.last_ip;
|
||||
if (ip) { try { shouldBlock ? await callBlock(ip) : await callUnblock(ip); dev.blocked = shouldBlock; if (info.ip) dev.last_ip = info.ip; } catch(e) {} }
|
||||
else { dev.blocked = shouldBlock; }
|
||||
}
|
||||
toast(`${user.name} ${shouldBlock ? 'blocked by schedule 🕐' : 'unblocked by schedule ✓'}`, shouldBlock?'w':'s');
|
||||
changed = true;
|
||||
}
|
||||
if (changed) { await callApply(); await saveCfg(); render(); }
|
||||
}
|
||||
function inRange(now, bt, ut) { if (!bt || !ut || bt === ut) return false; return bt < ut ? (now >= bt && now < ut) : (now >= bt || now < ut); }
|
||||
async function onSchedChange(userId, field, value) {
|
||||
const user = cfg.users.find(u => u.id === userId); if (!user) return;
|
||||
if (!user.schedule) user.schedule = { enabled:false, weekday:{block_time:'21:00',unblock_time:'07:00'}, weekend:{block_time:'22:00',unblock_time:'08:00'} };
|
||||
if (field === 'enabled') user.schedule.enabled = value;
|
||||
else { const [slot, key] = field.split('.'); user.schedule[slot][key] = value; }
|
||||
function inRange(now,bt,ut){if(!bt||!ut||bt===ut)return false;return bt<ut?(now>=bt&&now<ut):(now>=bt||now<ut);}
|
||||
async function onSchedChange(userId,field,value){
|
||||
const user=cfg.users.find(u=>u.id===userId);if(!user)return;
|
||||
if(!user.schedule)user.schedule={enabled:false,weekday:{block_time:'21:00',unblock_time:'07:00'},weekend:{block_time:'22:00',unblock_time:'08:00'}};
|
||||
if(field==='enabled')user.schedule.enabled=value;
|
||||
else{const[slot,key]=field.split('.');user.schedule[slot][key]=value;}
|
||||
await saveCfg();
|
||||
}
|
||||
|
||||
function uid() { return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2,6); }
|
||||
function openAddUserModal() { document.getElementById('u-name').value = ''; buildColorPicker(COLORS[cfg.users.length % COLORS.length]); document.getElementById('add-user-modal').classList.add('open'); }
|
||||
function createUser() {
|
||||
const name = document.getElementById('u-name').value.trim(); if (!name) { toast('Enter a name', 'e'); return; }
|
||||
const sel = document.querySelector('#col-picker .col-opt.sel'); const color = sel ? sel.dataset.c : COLORS[0];
|
||||
cfg.users.push({ id: uid(), name, color, blocked: false, devices: [], schedule: { enabled:false, weekday:{block_time:'21:00',unblock_time:'07:00'}, weekend:{block_time:'22:00',unblock_time:'08:00'} } });
|
||||
saveCfg(); render(); closeModal('add-user-modal'); toast(`${name} added`, 's');
|
||||
function uid(){return 'u'+Date.now().toString(36)+Math.random().toString(36).slice(2,6);}
|
||||
function openAddUserModal(){document.getElementById('u-name').value='';buildColorPicker(COLORS[cfg.users.length%COLORS.length]);document.getElementById('add-user-modal').classList.add('open');}
|
||||
function createUser(){
|
||||
const name=document.getElementById('u-name').value.trim();if(!name){toast('Enter a name','e');return;}
|
||||
const sel=document.querySelector('#col-picker .col-opt.sel');const color=sel?sel.dataset.c:COLORS[0];
|
||||
cfg.users.push({id:uid(),name,color,blocked:false,devices:[],schedule:{enabled:false,weekday:{block_time:'21:00',unblock_time:'07:00'},weekend:{block_time:'22:00',unblock_time:'08:00'}}});
|
||||
saveCfg();render();closeModal('add-user-modal');toast(`${name} added`,'s');
|
||||
}
|
||||
async function deleteUser(userId) {
|
||||
const user = cfg.users.find(u => u.id === userId);
|
||||
if (!user || !confirm(`Remove ${user.name} and unblock all their devices?`)) return;
|
||||
let anyBlocked = false;
|
||||
for (const dev of user.devices.filter(d => d.blocked)) { const ip = deviceInfo(dev.mac).ip || dev.last_ip; if (ip) { try { await callUnblock(ip); anyBlocked = true; } catch(e) {} } }
|
||||
if (anyBlocked) await callApply();
|
||||
cfg.users = cfg.users.filter(u => u.id !== userId); await saveCfg(); render(); toast(`${user.name} removed`, 's');
|
||||
async function deleteUser(userId){
|
||||
const user=cfg.users.find(u=>u.id===userId);if(!user||!confirm(`Remove ${user.name} and unblock all their devices?`))return;
|
||||
let anyBlocked=false;
|
||||
for(const dev of user.devices.filter(d=>d.blocked)){const ip=deviceInfo(dev.mac).ip||dev.last_ip;if(ip){try{await callUnblock(ip);anyBlocked=true;}catch(e){}}}
|
||||
if(anyBlocked)await callApply();
|
||||
cfg.users=cfg.users.filter(u=>u.id!==userId);await saveCfg();render();toast(`${user.name} removed`,'s');
|
||||
}
|
||||
|
||||
function openAddDeviceModal(userId) {
|
||||
devModalUserId = userId; document.getElementById('d-name').value = ''; document.getElementById('d-mac').value = '';
|
||||
const disc = discoveredDevices(); const list = document.getElementById('disc-list');
|
||||
if (!disc.length) { list.innerHTML = '<div style="padding:12px;text-align:center;font-size:.82rem;color:var(--text-muted)">No unassigned devices found</div>'; }
|
||||
else { list.innerHTML = disc.map(d => `<div class="disc-item" data-mac="${d.mac}" data-name="${esc(d.name)}" onclick="pickDisc(this)"><span style="width:7px;height:7px;border-radius:50%;background:${d.online?'var(--success)':'var(--text-muted)'};flex-shrink:0"></span><div><div class="disc-name">${esc(d.name)}</div><div class="disc-sub">${d.mac}${d.ip?' · '+d.ip:''}</div></div></div>`).join(''); }
|
||||
function openAddDeviceModal(userId){
|
||||
devModalUserId=userId;document.getElementById('d-name').value='';document.getElementById('d-mac').value='';
|
||||
const disc=discoveredDevices();const list=document.getElementById('disc-list');
|
||||
if(!disc.length){list.innerHTML='<div style="padding:12px;text-align:center;font-size:.82rem;color:var(--text-muted)">No unassigned devices found</div>';}
|
||||
else{list.innerHTML=disc.map(d=>`<div class="disc-item" data-mac="${d.mac}" data-name="${esc(d.name)}" onclick="pickDisc(this)"><span style="width:7px;height:7px;border-radius:50%;background:${d.online?'var(--success)':'var(--text-muted)'};flex-shrink:0"></span><div><div class="disc-name">${esc(d.name)}</div><div class="disc-sub">${d.mac}${d.ip?' · '+d.ip:''}</div></div></div>`).join('');}
|
||||
openModal('add-dev-modal');
|
||||
}
|
||||
function pickDisc(el) { document.querySelectorAll('.disc-item').forEach(i => i.classList.remove('sel')); el.classList.add('sel'); document.getElementById('d-name').value = el.dataset.name; document.getElementById('d-mac').value = el.dataset.mac; }
|
||||
async function addDevice() {
|
||||
const name = document.getElementById('d-name').value.trim(); const mac = document.getElementById('d-mac').value.trim().toLowerCase();
|
||||
if (!name) { toast('Enter a device name', 'e'); return; }
|
||||
if (!MAC_RE.test(mac)) { toast('Invalid MAC (format: aa:bb:cc:dd:ee:ff)', 'e'); return; }
|
||||
if (cfg.users.some(u => u.devices.some(d => normMac(d.mac) === normMac(mac)))) { toast('That MAC is already assigned', 'e'); return; }
|
||||
const user = cfg.users.find(u => u.id === devModalUserId); if (!user) return;
|
||||
const info = deviceInfo(mac); user.devices.push({ name, mac: mac.toLowerCase(), blocked: false, last_ip: info.ip || '' });
|
||||
await saveCfg(); render(); closeModal('add-dev-modal'); toast(`${name} added to ${user.name}`, 's');
|
||||
function pickDisc(el){document.querySelectorAll('.disc-item').forEach(i=>i.classList.remove('sel'));el.classList.add('sel');document.getElementById('d-name').value=el.dataset.name;document.getElementById('d-mac').value=el.dataset.mac;}
|
||||
async function addDevice(){
|
||||
const name=document.getElementById('d-name').value.trim();const mac=document.getElementById('d-mac').value.trim().toLowerCase();
|
||||
if(!name){toast('Enter a device name','e');return;}if(!MAC_RE.test(mac)){toast('Invalid MAC (format: aa:bb:cc:dd:ee:ff)','e');return;}
|
||||
if(cfg.users.some(u=>u.devices.some(d=>normMac(d.mac)===normMac(mac)))){toast('That MAC is already assigned','e');return;}
|
||||
const user=cfg.users.find(u=>u.id===devModalUserId);if(!user)return;
|
||||
const info=deviceInfo(mac);user.devices.push({name,mac:mac.toLowerCase(),blocked:false,last_ip:info.ip||''});
|
||||
await saveCfg();render();closeModal('add-dev-modal');toast(`${name} added to ${user.name}`,'s');
|
||||
}
|
||||
async function removeDevice(userId, mac) {
|
||||
const user = cfg.users.find(u => u.id === userId); const dev = user?.devices.find(d => d.mac === mac); if (!dev) return;
|
||||
if (dev.blocked) { const ip = deviceInfo(mac).ip || dev.last_ip; if (ip) { try { await callUnblock(ip); await callApply(); } catch(e) {} } }
|
||||
user.devices = user.devices.filter(d => d.mac !== mac); await saveCfg(); render(); toast(`${dev.name} removed`, 's');
|
||||
async function removeDevice(userId,mac){
|
||||
const user=cfg.users.find(u=>u.id===userId);const dev=user?.devices.find(d=>d.mac===mac);if(!dev)return;
|
||||
if(dev.blocked){const ip=deviceInfo(mac).ip||dev.last_ip;if(ip){try{await callUnblock(ip);await callApply();}catch(e){}}}
|
||||
user.devices=user.devices.filter(d=>d.mac!==mac);await saveCfg();render();toast(`${dev.name} removed`,'s');
|
||||
}
|
||||
|
||||
function schedRender() { clearTimeout(renderTimer); renderTimer = setTimeout(render, 400); }
|
||||
function render() {
|
||||
const grid = document.getElementById('grid');
|
||||
if (!cfg.users.length) {
|
||||
grid.innerHTML = '<div class="empty"><div class="big">👨👩👧👦</div><h2>No users yet</h2><p>Add a user to start managing internet access.</p><button class="btn btn-primary" style="margin-top:14px" onclick="openModal(\'add-user-modal\')">+ Add First User</button></div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = cfg.users.map(u => renderUser(u)).join('') + '<div class="add-user-tile" onclick="openModal(\'add-user-modal\')"><div class="add-user-tile-inner"><div class="plus">+</div><p>Add User</p></div></div>';
|
||||
function schedRender(){clearTimeout(renderTimer);renderTimer=setTimeout(render,400);}
|
||||
function render(){
|
||||
const grid=document.getElementById('grid');
|
||||
if(!cfg.users.length){grid.innerHTML='<div class="empty"><div class="big">👨👩👧👦</div><h2>No users yet</h2><p>Add a user to start managing internet access.</p><button class="btn btn-primary" style="margin-top:14px" onclick="openModal(\'add-user-modal\')">+ Add First User</button></div>';return;}
|
||||
grid.innerHTML=cfg.users.map(u=>renderUser(u)).join('')+'<div class="add-user-tile" onclick="openModal(\'add-user-modal\')"><div class="add-user-tile-inner"><div class="plus">+</div><p>Add User</p></div></div>';
|
||||
}
|
||||
function renderUser(user) {
|
||||
const sched = user.schedule || { enabled:false, weekday:{block_time:'21:00',unblock_time:'07:00'}, weekend:{block_time:'22:00',unblock_time:'08:00'} };
|
||||
const open = !!schedOpen[user.id];
|
||||
const devHtml = user.devices.length ? user.devices.map(d => renderDev(user.id, d)).join('') : '<div class="no-dev">No devices assigned</div>';
|
||||
return `<div class="user-card${user.blocked?' blocked':''}"><div class="card-head"><span class="color-dot" style="background:${user.color}"></span><span class="user-name-el">${esc(user.name)}</span><span class="status-lbl${user.blocked?' blocked':''}">${user.blocked?'Blocked':'Online'}</span><label class="tog-wrap" title="${user.blocked?'Unblock':'Block'} all"><div class="tog"><input type="checkbox" ${user.blocked?'checked':''} onchange="toggleUser('${user.id}')"><div class="tog-track"></div><div class="tog-thumb"></div></div></label><button class="del-btn" onclick="deleteUser('${user.id}')" title="Remove user">✕</button></div><div class="card-body"><div class="sec-title">Devices (${user.devices.length})</div><div class="dev-list">${devHtml}</div><button class="btn btn-ghost btn-sm" onclick="openAddDeviceModal('${user.id}')">+ Add Device</button><div class="sched-section"><div class="sched-head" onclick="toggleSched('${user.id}')"><span class="sched-title">🕐 Schedule${sched.enabled?' (active)':''}</span><span class="sched-chev${open?' open':''}">▼</span></div><div class="sched-body${open?' open':''}"><div class="sched-en-row"><label class="tog-wrap"><div class="tog sm grn"><input type="checkbox" ${sched.enabled?'checked':''} onchange="onSchedChange('${user.id}','enabled',this.checked)"><div class="tog-track"></div><div class="tog-thumb"></div></div><span style="font-size:.78rem;color:var(--text-muted)">Enable schedule blocking</span></label></div><div class="sched-row"><label>Weekdays</label><input class="time-in" type="time" value="${sched.weekday.block_time}" onchange="onSchedChange('${user.id}','weekday.block_time',this.value)" /><span class="time-sep">→</span><input class="time-in" type="time" value="${sched.weekday.unblock_time}" onchange="onSchedChange('${user.id}','weekday.unblock_time',this.value)" /></div><div class="sched-row"><label>Weekend</label><input class="time-in" type="time" value="${sched.weekend.block_time}" onchange="onSchedChange('${user.id}','weekend.block_time',this.value)" /><span class="time-sep">→</span><input class="time-in" type="time" value="${sched.weekend.unblock_time}" onchange="onSchedChange('${user.id}','weekend.unblock_time',this.value)" /></div><p class="sched-hint">Block starts at first time, unblocks at second. Overnight ranges work (e.g. 21:00 → 07:00).</p></div></div></div></div>`;
|
||||
function renderUser(user){
|
||||
const sched=user.schedule||{enabled:false,weekday:{block_time:'21:00',unblock_time:'07:00'},weekend:{block_time:'22:00',unblock_time:'08:00'}};
|
||||
const open=!!schedOpen[user.id];
|
||||
const devHtml=user.devices.length?user.devices.map(d=>renderDev(user.id,d)).join(''):'<div class="no-dev">No devices assigned</div>';
|
||||
return `<div class="user-card${user.blocked?' blocked':''}"><div class="card-head"><span class="color-dot" style="background:${user.color}"></span><span class="user-name-el">${esc(user.name)}</span><span class="status-lbl${user.blocked?' blocked':''}">${user.blocked?'Blocked':'Online'}</span><label class="tog-wrap" title="${user.blocked?'Unblock':'Block'} all"><div class="tog"><input type="checkbox" ${user.blocked?'checked':''} onchange="toggleUser('${user.id}')"><div class="tog-track"></div><div class="tog-thumb"></div></div></label><button class="del-btn" onclick="deleteUser('${user.id}')" title="Remove user">✕</button></div><div class="card-body"><div class="sec-title">Devices (${user.devices.length})</div><div class="dev-list">${devHtml}</div><button class="btn btn-ghost btn-sm" onclick="openAddDeviceModal('${user.id}')">+ Add Device</button><div class="sched-section"><div class="sched-head" onclick="toggleSched('${user.id}')"><span class="sched-title">🕐 Schedule${sched.enabled?' (active)':''}</span><span class="sched-chev${open?' open':''}">▼</span></div><div class="sched-body${open?' open':''}"><div class="sched-en-row"><label class="tog-wrap"><div class="tog sm grn"><input type="checkbox" ${sched.enabled?'checked':''} onchange="onSchedChange('${user.id}','enabled',this.checked)"><div class="tog-track"></div><div class="tog-thumb"></div></div><span style="font-size:.78rem;color:var(--text-muted)">Enable schedule blocking</span></label></div><div class="sched-row"><label>Weekdays</label><input class="time-in" type="time" value="${sched.weekday.block_time}" onchange="onSchedChange('${user.id}','weekday.block_time',this.value)"/><span class="time-sep">→</span><input class="time-in" type="time" value="${sched.weekday.unblock_time}" onchange="onSchedChange('${user.id}','weekday.unblock_time',this.value)"/></div><div class="sched-row"><label>Weekend</label><input class="time-in" type="time" value="${sched.weekend.block_time}" onchange="onSchedChange('${user.id}','weekend.block_time',this.value)"/><span class="time-sep">→</span><input class="time-in" type="time" value="${sched.weekend.unblock_time}" onchange="onSchedChange('${user.id}','weekend.unblock_time',this.value)"/></div><p class="sched-hint">Block starts at first time, unblocks at second. Overnight ranges work (e.g. 21:00 → 07:00).</p></div></div></div></div>`;
|
||||
}
|
||||
function renderDev(userId, dev) {
|
||||
const info = deviceInfo(dev.mac); const sub = [dev.mac, info.ip || dev.last_ip].filter(Boolean).join(' · ');
|
||||
function renderDev(userId,dev){
|
||||
const info=deviceInfo(dev.mac);const sub=[dev.mac,info.ip||dev.last_ip].filter(Boolean).join(' · ');
|
||||
return `<div class="dev-item${dev.blocked?' blocked':''}"><span class="online-dot${info.online?' on':''}" title="${info.online?'Online':'Offline'}"></span><div class="dev-info"><div class="dev-name">${esc(dev.name)}</div><div class="dev-sub">${sub}</div></div><div class="dev-acts"><label class="tog-wrap" title="${dev.blocked?'Unblock':'Block'}"><div class="tog sm"><input type="checkbox" ${dev.blocked?'checked':''} onchange="toggleDevice('${userId}','${dev.mac}')"><div class="tog-track"></div><div class="tog-thumb"></div></div></label><button class="del-btn" onclick="removeDevice('${userId}','${dev.mac}')" title="Remove device">✕</button></div></div>`;
|
||||
}
|
||||
function toggleSched(userId) { schedOpen[userId] = !schedOpen[userId]; render(); }
|
||||
function buildColorPicker(presel) { document.getElementById('col-picker').innerHTML = COLORS.map(c => `<span class="col-opt${c===presel?' sel':''}" data-c="${c}" style="background:${c}" onclick="pickColor(this)"></span>`).join(''); }
|
||||
function pickColor(el) { document.querySelectorAll('.col-opt').forEach(o => o.classList.remove('sel')); el.classList.add('sel'); }
|
||||
function openModal(id) {
|
||||
if (id === 'add-user-modal') openAddUserModal();
|
||||
else { if (id === 'settings-modal') { document.getElementById('set-url').value = prefs.url; document.getElementById('set-tok').value = prefs.token; } document.getElementById(id).classList.add('open'); }
|
||||
function toggleSched(userId){schedOpen[userId]=!schedOpen[userId];render();}
|
||||
function buildColorPicker(presel){document.getElementById('col-picker').innerHTML=COLORS.map(c=>`<span class="col-opt${c===presel?' sel':''}" data-c="${c}" style="background:${c}" onclick="pickColor(this)"></span>`).join('');}
|
||||
function pickColor(el){document.querySelectorAll('.col-opt').forEach(o=>o.classList.remove('sel'));el.classList.add('sel');}
|
||||
function openModal(id){if(id==='add-user-modal')openAddUserModal();else{if(id==='settings-modal'){document.getElementById('set-url').value=prefs.url;document.getElementById('set-tok').value=prefs.token;}document.getElementById(id).classList.add('open');}}
|
||||
function closeModal(id){document.getElementById(id).classList.remove('open');}
|
||||
function overlayClick(e,id){if(e.target===e.currentTarget)closeModal(id);}
|
||||
function toast(msg,type='s'){
|
||||
const c=document.getElementById('toasts');const el=document.createElement('div');
|
||||
el.className=`toast ${type}`;el.textContent=msg;c.appendChild(el);
|
||||
setTimeout(()=>{el.style.opacity='0';setTimeout(()=>el.remove(),300);},3800);
|
||||
}
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
function overlayClick(e, id) { if (e.target === e.currentTarget) closeModal(id); }
|
||||
function toast(msg, type='s') {
|
||||
const c = document.getElementById('toasts'); const el = document.createElement('div');
|
||||
el.className = `toast ${type}`; el.textContent = msg; c.appendChild(el);
|
||||
setTimeout(() => { el.style.opacity='0'; setTimeout(()=>el.remove(),300); }, 3800);
|
||||
}
|
||||
function setBadge(state) { const el = document.getElementById('conn-badge'); el.className = `badge ${state}`; el.textContent = state.charAt(0).toUpperCase() + state.slice(1); }
|
||||
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function pad2(n) { return String(n).padStart(2,'0'); }
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (loadPrefs()) { document.getElementById('setup-screen').style.display = 'none'; document.getElementById('app').classList.add('visible'); connect(); }
|
||||
else { document.getElementById('s-url').value = window.location.origin; }
|
||||
function setBadge(state){const el=document.getElementById('conn-badge');el.className=`badge ${state}`;el.textContent=state.charAt(0).toUpperCase()+state.slice(1);}
|
||||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
function pad2(n){return String(n).padStart(2,'0');}
|
||||
document.addEventListener('DOMContentLoaded',()=>{
|
||||
if(loadPrefs()){document.getElementById('setup-screen').style.display='none';document.getElementById('app').classList.add('visible');connect();}
|
||||
else{document.getElementById('s-url').value=window.location.origin;}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user