421 lines
32 KiB
HTML
421 lines
32 KiB
HTML
<!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="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-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 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 & Reconnect</button></div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
const DHCP='sensor.opnsense_dhcp_leases';
|
||
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;
|
||
|
||
function loadPrefs(){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','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)){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(){
|
||
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?.rows||[];}
|
||
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||r.mac_address||'')===m)||null;
|
||
}
|
||
function deviceInfo(mac){
|
||
const lease=getLease(mac);
|
||
if(lease){
|
||
const ip=lease.ipaddr||lease.address||lease.ip||'';
|
||
const online=lease.online==='1'||lease.online===true||lease.state==='active'||lease.state==='online';
|
||
return{online,ip,label:lease.hostname||lease.descr||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||r.mac_address||'');
|
||
if(!mac||assigned.has(mac))continue;
|
||
out.push({mac,name:r.hostname||r.descr||mac,ip:r.ipaddr||r.address||r.ip||'',online:r.online==='1'||r.online===true||r.state==='active'});
|
||
}
|
||
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 loaded — add <code>opnsense_leases_url</code> to secrets.yaml and restart 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 found devices are assigned':'No DHCP data yet'}</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(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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
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>
|