Files
ha-parental-controls/www/parental_controls.html
T

382 lines
31 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;--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}
#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}
.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.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)}
.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-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}
.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)}
.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)}
.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-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}
.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-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}
.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)}
.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)}
.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}
#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}
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>
</head>
<body>
<div id="setup-screen">
<div class="setup-card">
<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>
<button class="btn btn-primary btn-full" onclick="doSetup()">Connect →</button>
</div>
</div>
<div id="app">
<header>
<div class="h-left"><span class="logo-icon">🛡️</span><h1>Parental Controls</h1></div>
<div class="h-right">
<span id="conn-badge" class="badge connecting">Connecting</span>
<button class="icon-btn" onclick="openModal('settings-modal')" title="Settings"></button>
<button class="btn btn-primary" 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="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="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="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-acts"><button class="btn btn-ghost" onclick="closeModal('settings-modal')">Cancel</button><button class="btn btn-primary" onclick="saveSettings()">Save &amp; 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;
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 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){
// 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 '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(){
setBadge('connected');
// 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:',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);
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();}
}
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;}
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');
}
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('');}
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');
}
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 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(' · ');
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 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,'&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-screen').style.display='none';document.getElementById('app').classList.add('visible');connect();}
else{document.getElementById('s-url').value=window.location.origin;}
});
</script>
</body>
</html>