Files
ha-parental-controls/www/parental_controls.html
T
jessikitty bd4996492d Auto-detect URL from page origin — works on HTTP, HTTPS, LAN IP, or domain
Removed CONFIG_HA_URL. The dashboard now always uses window.location.origin
so it adapts to however you access HA:
- https://ha.hideawaygaming.com.au → wss:// WebSocket
- http://10.0.0.55:8123 → ws:// WebSocket
Same token, no config change needed.
2026-05-18 12:19:21 +10:00

386 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Parental Controls</title>
<style>
:root{--bg:#0d1117;--sur:#161b24;--sur2:#1e2535;--sur3:#252d3d;--bdr:#2a3347;--txt:#dde3f0;--muted:#6b7a99;--acc:#6b8bef;--ach:#7e9cf5;--ok:#3ecf8e;--ng:#f56565;--warn:#f6ad55;--r:14px;--rs:8px;--sh: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(--txt);font-size:15px}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--bdr);border-radius:3px}
#setup{position:fixed;inset:0;background:var(--bg);display:flex;align-items:center;justify-content:center;z-index:999;padding:20px}
.sc{background:var(--sur);border:1px solid var(--bdr);border-radius:var(--r);padding:36px;width:min(440px,100%);box-shadow:var(--sh)}
.sc-logo{font-size:2.8rem;display:block;margin-bottom:12px}
.sc h1{font-size:1.5rem;font-weight:700;margin-bottom:6px;letter-spacing:-.02em}
.sc p{color:var(--muted);font-size:.875rem;line-height:1.6;margin-bottom:22px}
#app{display:none;height:100vh;flex-direction:column}#app.on{display:flex}
header{background:var(--sur);border-bottom:1px solid var(--bdr);padding:12px 20px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;gap:10px}
.hl{display:flex;align-items:center;gap:10px;min-width:0}.hl h1{font-size:1.1rem;font-weight:700;letter-spacing:-.02em;white-space:nowrap}.logo{font-size:1.35rem}.hr{display:flex;align-items:center;gap:8px;flex-shrink:0}
main{flex:1;overflow-y:auto;padding:20px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:16px;align-items:start}
.status-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.sdot{width:6px;height:6px;border-radius:50%;background:var(--muted);flex-shrink:0}
.sdot.ok{background:var(--ok);box-shadow:0 0 4px var(--ok)}.sdot.err{background:var(--ng)}.sdot.conn{background:var(--warn)}
.btn{padding:7px 14px;border-radius:var(--rs);border:none;cursor:pointer;font-size:.83rem;font-weight:600;transition:all var(--tr);white-space:nowrap;line-height:1.4}
.btn-p{background:var(--acc);color:#fff}.btn-p:hover{background:var(--ach);transform:translateY(-1px)}
.btn-g{background:transparent;color:var(--muted);border:1px solid var(--bdr)}.btn-g:hover{background:var(--sur2);color:var(--txt)}
.btn-sm{padding:4px 10px;font-size:.78rem}.btn-full{width:100%;margin-top:6px;padding:10px}
.ibtn{background:transparent;border:1px solid var(--bdr);color:var(--muted);border-radius:var(--rs);width:32px;height:32px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:.9rem;transition:all var(--tr)}.ibtn:hover{background:var(--sur2);color:var(--txt)}
.tw{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
.tog{position:relative;width:40px;height:22px;flex-shrink:0}.tog input{opacity:0;width:0;height:0;position:absolute}
.tt{position:absolute;inset:0;border-radius:11px;background:var(--bdr);transition:background var(--tr)}
.th{position:absolute;width:16px;height:16px;top:3px;left:3px;background:var(--muted);border-radius:50%;transition:transform var(--tr),background var(--tr)}
.tog input:checked~.tt{background:var(--ng)}.tog input:checked~.th{transform:translateX(18px);background:#fff}
.tog.grn input:checked~.tt{background:var(--ok)}
.tog.sm{width:32px;height:18px}.tog.sm .th{width:12px;height:12px}.tog.sm input:checked~.th{transform:translateX(14px)}
.card{background:var(--sur);border-radius:var(--r);border:1px solid var(--bdr);overflow:hidden;transition:box-shadow var(--tr),border-color var(--tr)}
.card.blocked{border-color:rgba(245,101,101,.4)}.card:hover{box-shadow:var(--sh)}
.ch{padding:13px 16px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--bdr);background:var(--sur2)}
.cdot{width:11px;height:11px;border-radius:50%;flex-shrink:0}
.cname{flex:1;font-weight:700;font-size:.95rem;letter-spacing:-.01em;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.slbl{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);flex-shrink:0}.slbl.blocked{color:var(--ng)}
.delbtn{background:transparent;border:none;color:var(--muted);cursor:pointer;font-size:.95rem;padding:2px 5px;border-radius:4px;transition:all var(--tr);line-height:1;flex-shrink:0}.delbtn:hover{color:var(--ng);background:rgba(245,101,101,.1)}
.cb{padding:14px 16px}
.stitle{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:9px}
.devlist{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}
.ditem{background:var(--sur2);border-radius:var(--rs);padding:8px 11px;display:flex;align-items:center;gap:8px;border:1px solid transparent;transition:border-color var(--tr)}.ditem.blocked{border-color:rgba(245,101,101,.3)}
.odot{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:var(--muted)}.odot.on{background:var(--ok);box-shadow:0 0 5px var(--ok)}
.dinfo{flex:1;min-width:0}.dname{font-size:.85rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dsub{font-size:.69rem;color:var(--muted);font-family:monospace;margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dacts{display:flex;align-items:center;gap:5px;flex-shrink:0}.nodev{color:var(--muted);font-size:.83rem;text-align:center;padding:10px 0}
.sched{border-top:1px solid var(--bdr);padding-top:13px;margin-top:13px}
.shed-hd{display:flex;align-items:center;justify-content:space-between;cursor:pointer}
.shed-ttl{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.shed-chev{color:var(--muted);transition:transform var(--tr);font-size:.68rem;line-height:1}.shed-chev.open{transform:rotate(180deg)}
.shed-body{display:none;margin-top:11px}.shed-body.open{display:block}
.shed-en{display:flex;align-items:center;gap:8px;margin-bottom:9px}
.shed-row{display:flex;align-items:center;gap:7px;margin-bottom:7px}.shed-row label{font-size:.76rem;color:var(--muted);width:65px;flex-shrink:0}
.tin{background:var(--sur3);border:1px solid var(--bdr);color:var(--txt);border-radius:var(--rs);padding:4px 7px;font-size:.8rem;width:84px;transition:border-color var(--tr)}.tin:focus{outline:none;border-color:var(--acc)}
.tsep{color:var(--muted);font-size:.76rem}.shed-hint{font-size:.7rem;color:var(--muted);margin-top:3px;line-height:1.4}
.addtile{background:var(--sur);border-radius:var(--r);border:2px dashed var(--bdr);display:flex;align-items:center;justify-content:center;min-height:180px;cursor:pointer;transition:all var(--tr)}.addtile:hover{border-color:var(--acc);background:rgba(107,139,239,.05)}
.addtile-in{text-align:center;color:var(--muted)}.addtile-in .plus{font-size:1.8rem;margin-bottom:5px;line-height:1}.addtile-in p{font-size:.83rem}
.empty{text-align:center;padding:56px 20px;grid-column:1/-1;color:var(--muted)}.empty .big{font-size:2.8rem;margin-bottom:12px}.empty h2{font-size:1.05rem;color:var(--txt);margin-bottom:5px}.empty p{font-size:.85rem}
.ov{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)}.ov.open{opacity:1;pointer-events:all}
.modal{background:var(--sur);border-radius:var(--r);border:1px solid var(--bdr);padding:24px;width:min(460px,100%);box-shadow:var(--sh);transform:translateY(14px);transition:transform var(--tr)}.ov.open .modal{transform:translateY(0)}
.modal h2{font-size:1.05rem;font-weight:700;margin-bottom:16px;letter-spacing:-.01em}
.fg{margin-bottom:12px}.fg label{display:block;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:4px}
.fi{width:100%;background:var(--sur2);border:1px solid var(--bdr);color:var(--txt);border-radius:var(--rs);padding:8px 11px;font-size:.875rem;transition:border-color var(--tr)}.fi:focus{outline:none;border-color:var(--acc)}
.macts{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
.col-row{display:flex;gap:7px;flex-wrap:wrap}.copt{width:24px;height:24px;border-radius:50%;cursor:pointer;border:3px solid transparent;transition:all var(--tr)}.copt.sel{border-color:#fff;transform:scale(1.18)}
.dlist{max-height:200px;overflow-y:auto;border:1px solid var(--bdr);border-radius:var(--rs);margin-top:5px;background:var(--sur2)}
.dlist-item{padding:8px 11px;cursor:pointer;transition:background var(--tr);display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--bdr)}.dlist-item:last-child{border-bottom:none}.dlist-item:hover{background:var(--sur3)}.dlist-item.sel{background:rgba(107,139,239,.18)}
.dl-name{font-size:.85rem;font-weight:600}.dl-sub{font-size:.7rem;color:var(--muted);font-family:monospace;margin-top:1px}
#toasts{position:fixed;bottom:20px;right:20px;z-index:400;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{background:var(--sur);border:1px solid var(--bdr);border-radius:var(--rs);padding:10px 14px;font-size:.83rem;font-weight:500;box-shadow:var(--sh);max-width:300px;transition:opacity .3s;border-left:3px solid var(--bdr)}
.toast.s{border-left-color:var(--ok)}.toast.e{border-left-color:var(--ng)}.toast.w{border-left-color:var(--warn)}
@keyframes ti{from{transform:translateX(110%);opacity:0}to{transform:translateX(0);opacity:1}}.toast{animation:ti .22s ease}
.ibox{background:rgba(107,139,239,.1);border:1px solid rgba(107,139,239,.3);border-radius:var(--rs);padding:10px 13px;font-size:.82rem;color:var(--muted);margin-bottom:12px;line-height:1.5}.ibox strong{color:var(--txt)}
input[type=time]::-webkit-calendar-picker-indicator{filter:invert(.5)}
@media(max-width:580px){header{padding:10px 14px}main{padding:14px}.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div id="setup">
<div class="sc">
<span class="sc-logo">🛡️</span>
<h1>Parental Controls</h1>
<p>Enter your Home Assistant URL and a Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens).</p>
<div class="fg"><label>Home Assistant URL</label><input id="s-url" class="fi" type="text" placeholder="http://10.0.0.55:8123" 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-p btn-full" onclick="doSetup()">Connect →</button>
</div>
</div>
<div id="app">
<header>
<div class="hl"><span class="logo">🛡️</span><h1>Parental Controls</h1></div>
<div class="hr">
<div class="status-row">
<span class="sdot" id="ha-dot"></span>
<span id="ha-lbl" style="font-size:.74rem;color:var(--muted)">HA</span>
<span class="sdot" id="dhcp-dot" style="margin-left:4px"></span>
<span id="dhcp-lbl" style="font-size:.74rem;color:var(--muted)">DHCP</span>
</div>
<button class="ibtn" onclick="openModal('settings-modal')" title="Settings"></button>
<button class="btn btn-p" onclick="openModal('add-user-modal')"> Add User</button>
</div>
</header>
<main><div id="grid" class="grid"></div></main>
</div>
<div id="toasts"></div>
<div id="add-user-modal" class="ov" onclick="ovc(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. William"/></div>
<div class="fg"><label>Colour</label><div class="col-row" id="col-picker"></div></div>
<div class="macts"><button class="btn btn-g" onclick="closeModal('add-user-modal')">Cancel</button><button class="btn btn-p" onclick="createUser()">Create</button></div>
</div>
</div>
<div id="add-dev-modal" class="ov" onclick="ovc(event,'add-dev-modal')">
<div class="modal"><h2>Add Device</h2>
<div id="dhcp-note"></div>
<div class="fg"><label>Device Name</label><input id="d-name" class="fi" type="text" placeholder="e.g. William's Phone"/></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 from network</label><div id="disc-list" class="dlist"></div></div>
<div class="macts"><button class="btn btn-g" onclick="closeModal('add-dev-modal')">Cancel</button><button class="btn btn-p" onclick="addDevice()">Add Device</button></div>
</div>
</div>
<div id="settings-modal" class="ov" onclick="ovc(event,'settings-modal')">
<div class="modal"><h2>⚙ Settings</h2>
<div id="auto-auth-info" class="ibox" style="display:none"><strong>✓ Token configured in file</strong> — credentials are set in parental_controls.html. To change them, edit the CONFIG section at the top of the script.</div>
<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="ibox"><strong>Config not saving?</strong> Go to HA → Developer Tools → Actions → call <code>input_text.reload</code>, then hard-refresh this page.</div>
<div class="macts"><button class="btn btn-g" onclick="closeModal('settings-modal')">Cancel</button><button class="btn btn-p" onclick="saveSettings()">Save &amp; Reconnect</button></div>
</div>
</div>
<script>
const DHCP='sensor.home_dhcp_leases_lan';
const CHUNKS=12, CHUNK_SIZE=250;
const chunkId=i=>`input_text.parental_config_${i}`;
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 devTargetUser=null,schedOpen={},renderTimer=null,lastSaveTime=0;
// =====================================================================
// CONFIGURATION — Set token once, works on every device automatically
// Generate a Long-Lived Access Token in HA: Profile → Security → Long-Lived Access Tokens
// URL auto-detects from page — works on HTTP, HTTPS, LAN IP, or domain
// =====================================================================
const CONFIG_HA_TOKEN = ''; // Paste your Long-Lived Access Token here
// =====================================================================
function loadPrefs(){
if(CONFIG_HA_TOKEN){
prefs={url:window.location.origin,token:CONFIG_HA_TOKEN};
return true;
}
try{const p=localStorage.getItem('pc2_prefs');if(p){prefs=JSON.parse(p);return true;}}catch(e){}
return false;
}
function savePrefs(){localStorage.setItem('pc2_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};savePrefs();
document.getElementById('setup').style.display='none';
document.getElementById('app').classList.add('on');
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};savePrefs();
closeModal('settings-modal');if(ws)ws.close();connect();
}
function connect(){
setHA('conn');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=()=>{setHA('err');retryTimer=setTimeout(connect,6000);};
}catch(ex){setHA('err');retryTimer=setTimeout(connect,6000);}
}
function dispatch(msg){
switch(msg.type){
case 'auth_required': ws.send(JSON.stringify({type:'auth',access_token:prefs.token})); break;
case 'auth_ok': onAuth(); break;
case 'auth_invalid': setHA('err'); toast('HA auth failed — check token in parental_controls.html','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'));}
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);
ws.send(JSON.stringify(msg));
});
}
async function onAuth(){
setHA('ok');
try{
const r=await fetch(`${prefs.url}/api/states`,{headers:{Authorization:`Bearer ${prefs.token}`}});
if(r.ok){const all=await r.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('states failed',e2);}
}
try{await send({type:'subscribe_events',event_type:'state_changed'});}catch(e){}
parseCfg();
updateDHCP();
render();
}
function onStateChange({entity_id,new_state}){
states[entity_id]=new_state;
if(entity_id===chunkId(0)){if(Date.now()-lastSaveTime>5000)parseCfg();schedRender();return;}
if(entity_id===DHCP){updateDHCP();schedRender();return;}
if(entity_id.startsWith('device_tracker.')){schedRender();}
}
function parseCfg(){
let combined='';
for(let i=0;i<CHUNKS;i++){const s=states[chunkId(i)];const v=s?.state||'';if(v==='unknown'||v==='unavailable')break;combined+=v;}
combined=combined.trim();
if(!combined||combined[0]!=='{'){try{combined=localStorage.getItem('pc2_backup')||'';}catch(e){}}
if(combined&&combined[0]==='{'){try{cfg=JSON.parse(combined);if(!cfg.users)cfg.users=[];}catch(e){cfg={users:[]};}}
}
async function saveCfg(){
lastSaveTime=Date.now();
const json=JSON.stringify(cfg);
try{localStorage.setItem('pc2_backup',json);}catch(e){}
const saves=[];
for(let i=0;i<CHUNKS;i++){const chunk=json.slice(i*CHUNK_SIZE,(i+1)*CHUNK_SIZE);saves.push(send({type:'call_service',domain:'input_text',service:'set_value',service_data:{entity_id:chunkId(i),value:chunk}}));}
try{await Promise.all(saves);}catch(e){toast('Save failed','e');console.error('saveCfg',e);}
}
function getLeases(){return states[DHCP]?.attributes?.Leases||[];}
function isOnline(lease){if(!lease.expires)return true;return new Date(lease.expires)>new Date();}
function updateDHCP(){
const s=states[DHCP];const dot=document.getElementById('dhcp-dot');const lbl=document.getElementById('dhcp-lbl');
if(!dot||!lbl)return;
if(!s||s.state==='unavailable'||s.state==='unknown'){dot.className='sdot err';lbl.textContent='DHCP ✗';}
else{dot.className='sdot ok';lbl.textContent=`DHCP (${s.state})`;}
}
function getLease(mac){const m=normMac(mac);return getLeases().find(r=>normMac(r.mac||'')===m)||null;}
function deviceInfo(mac){
const lease=getLease(mac);
if(lease)return{online:isOnline(lease),ip:lease.address||'',label:lease.hostname||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.','')};
}
return{online:false,ip:'',label:''};
}
function discoveredDevices(){
const assigned=new Set(cfg.users.flatMap(u=>u.devices.map(d=>normMac(d.mac))));const out=[];
for(const r of getLeases()){const mac=normMac(r.mac||'');if(!mac||assigned.has(mac))continue;out.push({mac,name:r.hostname||mac,ip:r.address||'',online:isOnline(r)});}
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)&&!out.find(x=>x.mac===mac))out.push({mac,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){}}
function resolveIp(mac,dev){return deviceInfo(mac).ip||dev.last_ip||'';}
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 ip=resolveIp(dev.mac,dev);if(ip){try{block?await callBlock(ip):await callUnblock(ip);dev.blocked=block;dev.last_ip=ip;ok++;}catch(e){fail++;}}else{dev.blocked=block;if(block)toast(`${dev.name}: no IP yet — will block when online`,'w');}}
await callApply();await saveCfg();render();toast(`${user.name} ${block?'blocked 🔒':'unblocked ✓'}`,block?'w':'s');
}
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 ip=resolveIp(mac,dev);
if(!ip){dev.blocked=block;await saveCfg();render();toast(block?`${dev.name}: no IP — will apply when online`:`${dev.name} unblocked`,block?'w':'s');return;}
try{block?await callBlock(ip):await callUnblock(ip);dev.blocked=block;dev.last_ip=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 wknd=now.getDay()===0||now.getDay()===6;let changed=false;
for(const user of cfg.users){
if(!user.schedule?.enabled)continue;const slot=wknd?user.schedule.weekend:user.schedule.weekday;
const sb=inRange(hhmm,slot.block_time,slot.unblock_time);if(sb===user.blocked)continue;user.blocked=sb;
for(const dev of user.devices){const ip=resolveIp(dev.mac,dev);if(ip){try{sb?await callBlock(ip):await callUnblock(ip);dev.blocked=sb;dev.last_ip=ip;}catch(e){}}else{dev.blocked=sb;}}
toast(`${user.name} ${sb?'blocked by schedule 🕐':'unblocked by schedule ✓'}`,sb?'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;}
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 .copt.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}?`))return;
for(const dev of user.devices.filter(d=>d.blocked)){const ip=resolveIp(dev.mac,dev);if(ip)try{await callUnblock(ip);}catch(e){}}
if(user.devices.some(d=>d.blocked))await callApply();
cfg.users=cfg.users.filter(u=>u.id!==userId);await saveCfg();render();toast(`${user.name} removed`,'s');
}
function openAddDeviceModal(userId){
devTargetUser=userId;document.getElementById('d-name').value='';document.getElementById('d-mac').value='';
const disc=discoveredDevices();const list=document.getElementById('disc-list');const note=document.getElementById('dhcp-note');
const hasDHCP=!!states[DHCP]&&states[DHCP].state!=='unavailable'&&states[DHCP].state!=='unknown';
note.innerHTML=hasDHCP?'':`<div class="ibox">DHCP sensor not found. Check <code>sensor.home_dhcp_leases_lan</code> exists in HA. You can still enter a MAC manually.</div>`;
if(!disc.length){list.innerHTML=`<div style="padding:10px;text-align:center;font-size:.8rem;color:var(--muted)">${hasDHCP?'All devices already assigned':'No DHCP data available'}</div>`;}
else{list.innerHTML=disc.map(d=>`<div class="dlist-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(--ok)':'var(--muted)'};flex-shrink:0"></span><div><div class="dl-name">${esc(d.name)}</div><div class="dl-sub">${d.mac}${d.ip?' · '+d.ip:''}</div></div></div>`).join('');}
openModal('add-dev-modal');
}
function pickDisc(el){document.querySelectorAll('.dlist-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 (aa:bb:cc:dd:ee:ff)','e');return;}
if(cfg.users.some(u=>u.devices.some(d=>normMac(d.mac)===normMac(mac)))){toast('MAC already assigned','e');return;}
const user=cfg.users.find(u=>u.id===devTargetUser);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=resolveIp(mac,dev);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 family member to start managing their internet access.</p><button class="btn btn-p" style="margin-top:12px" onclick="openModal(\'add-user-modal\')"> Add First User</button></div>';return;}
grid.innerHTML=cfg.users.map(u=>renderUser(u)).join('')+'<div class="addtile" onclick="openModal(\'add-user-modal\')"><div class="addtile-in"><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="nodev">No devices — click Add Device</div>';
return `<div class="card${user.blocked?' blocked':''}"><div class="ch"><span class="cdot" style="background:${user.color}"></span><span class="cname">${esc(user.name)}</span><span class="slbl${user.blocked?' blocked':''}">${user.blocked?'Blocked':'Online'}</span><label class="tw" title="${user.blocked?'Unblock':'Block'} all"><div class="tog"><input type="checkbox" ${user.blocked?'checked':''} onchange="toggleUser('${user.id}')"><div class="tt"></div><div class="th"></div></div></label><button class="delbtn" onclick="deleteUser('${user.id}')" title="Remove">✕</button></div><div class="cb"><div class="stitle">Devices (${user.devices.length})</div><div class="devlist">${devHtml}</div><button class="btn btn-g btn-sm" onclick="openAddDeviceModal('${user.id}')"> Add Device</button><div class="sched"><div class="shed-hd" onclick="toggleSched('${user.id}')"><span class="shed-ttl">🕐 Schedule${sched.enabled?' (active)':''}</span><span class="shed-chev${open?' open':''}">▼</span></div><div class="shed-body${open?' open':''}"><div class="shed-en"><label class="tw"><div class="tog sm grn"><input type="checkbox" ${sched.enabled?'checked':''} onchange="onSchedChange('${user.id}','enabled',this.checked)"><div class="tt"></div><div class="th"></div></div><span style="font-size:.76rem;color:var(--muted)">Enable schedule blocking</span></label></div><div class="shed-row"><label>Weekdays</label><input class="tin" type="time" value="${sched.weekday.block_time}" onchange="onSchedChange('${user.id}','weekday.block_time',this.value)"/><span class="tsep">→</span><input class="tin" type="time" value="${sched.weekday.unblock_time}" onchange="onSchedChange('${user.id}','weekday.unblock_time',this.value)"/></div><div class="shed-row"><label>Weekend</label><input class="tin" type="time" value="${sched.weekend.block_time}" onchange="onSchedChange('${user.id}','weekend.block_time',this.value)"/><span class="tsep">→</span><input class="tin" type="time" value="${sched.weekend.unblock_time}" onchange="onSchedChange('${user.id}','weekend.unblock_time',this.value)"/></div><p class="shed-hint">Blocked between first and second time. Overnight works (e.g. 21:00 → 07:00).</p></div></div></div></div>`;
}
function renderDev(userId,dev){
const info=deviceInfo(dev.mac);const ip=info.ip||dev.last_ip;const sub=[dev.mac,ip].filter(Boolean).join(' · ');
return `<div class="ditem${dev.blocked?' blocked':''}"><span class="odot${info.online?' on':''}" title="${info.online?'Online':'Offline'}"></span><div class="dinfo"><div class="dname">${esc(dev.name)}</div><div class="dsub">${sub}</div></div><div class="dacts"><label class="tw" title="${dev.blocked?'Unblock':'Block'}"><div class="tog sm"><input type="checkbox" ${dev.blocked?'checked':''} onchange="toggleDevice('${userId}','${dev.mac}')"><div class="tt"></div><div class="th"></div></div></label><button class="delbtn" onclick="removeDevice('${userId}','${dev.mac}')" title="Remove">✕</button></div></div>`;
}
function toggleSched(userId){schedOpen[userId]=!schedOpen[userId];render();}
function buildColorPicker(presel){document.getElementById('col-picker').innerHTML=COLORS.map(c=>`<span class="copt${c===presel?' sel':''}" data-c="${c}" style="background:${c}" onclick="pickColor(this)"></span>`).join('');}
function pickColor(el){document.querySelectorAll('.copt').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('auto-auth-info').style.display=CONFIG_HA_TOKEN?'block':'none';}document.getElementById(id).classList.add('open');}}
function closeModal(id){document.getElementById(id).classList.remove('open');}
function ovc(e,id){if(e.target===e.currentTarget)closeModal(id);}
function setHA(state){
const dot=document.getElementById('ha-dot');const lbl=document.getElementById('ha-lbl');if(!dot||!lbl)return;
dot.className='sdot'+(state==='ok'?' ok':state==='err'?' err':' conn');
lbl.textContent=state==='ok'?'HA ✓':state==='err'?'HA ✗':'HA…';
}
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);},4000);
}
function normMac(m){return String(m||'').toLowerCase().replace(/-/g,':');}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function pad2(n){return String(n).padStart(2,'0');}
document.addEventListener('DOMContentLoaded',()=>{
if(loadPrefs()){document.getElementById('setup').style.display='none';document.getElementById('app').classList.add('on');connect();}
else{document.getElementById('s-url').value=window.location.origin;}
});
</script>
</body>
</html>