feat(1.4.0): admin dashboard with client cards, album/person selector, playback/power/config controls

This commit is contained in:
2026-05-22 19:48:23 +10:00
parent 1863da1a28
commit 4b9db2af5a
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frambe Admin</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 1.5rem; min-height: 100vh; }
h1 { font-size: 1.6rem; font-weight: 300; margin-bottom: 0.25rem; }
.header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 1rem; }
.header img { width: 48px; height: 48px; border-radius: 10px; }
.header .version { font-size: 0.8rem; color: #666; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-dot.online { background: #4ade80; }
.status-dot.sleeping { background: #fbbf24; }
.status-dot.playing { background: #60a5fa; }
.clients-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; }
.client-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1.25rem; transition: all 0.2s; }
.client-card:hover { border-color: rgba(99,102,241,0.3); background: rgba(255,255,255,0.06); }
.client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.client-name { font-size: 1.1rem; font-weight: 500; display: flex; align-items: center; gap: 0.5rem; }
.client-ip { font-size: 0.75rem; color: #888; font-family: monospace; }
.client-status { font-size: 0.8rem; color: #aaa; text-transform: capitalize; }
.name-input { background: transparent; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; color: #fff; font-size: 0.9rem; padding: 4px 8px; width: 140px; }
.name-input:focus { outline: none; border-color: #6366f1; }
.controls { display: flex; flex-direction: column; gap: 0.75rem; }
.control-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.control-label { font-size: 0.8rem; color: #888; min-width: 60px; }
.btn { padding: 6px 14px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.btn:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25); }
.btn.danger { background: rgba(239,68,68,0.15); border-color: #ef4444; color: #fca5a5; }
.btn.danger:hover { background: rgba(239,68,68,0.3); }
.btn.success { background: rgba(34,197,94,0.15); border-color: #22c55e; color: #86efac; }
.btn.success:hover { background: rgba(34,197,94,0.3); }
select { padding: 6px 10px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; max-width: 200px; }
select:focus { outline: none; border-color: #6366f1; }
option { background: #1a1a2e; color: #e0e0e0; }
input[type=range] { width: 120px; accent-color: #6366f1; }
.range-value { font-size: 0.8rem; color: #aaa; min-width: 30px; }
.toggle { position: relative; width: 40px; height: 22px; cursor: pointer; }
.toggle input { display: none; }
.toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.1); border-radius: 11px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: #888; border-radius: 50%; transition: 0.2s; }
.toggle input:checked + .toggle-slider { background: rgba(99,102,241,0.4); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); background: #a5b4fc; }
.empty-state { text-align: center; padding: 4rem 2rem; color: #666; }
.empty-state h2 { font-size: 1.2rem; font-weight: 400; margin-bottom: 0.5rem; color: #888; }
.ws-status { font-size: 0.75rem; padding: 4px 10px; border-radius: 20px; }
.ws-status.connected { background: rgba(34,197,94,0.15); color: #86efac; }
.ws-status.disconnected { background: rgba(239,68,68,0.15); color: #fca5a5; }
.divider { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 0.5rem 0; }
</style>
</head>
<body>
<div class="header">
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
<div><h1>Frambe Admin</h1><span class="version" id="version-text">Connecting...</span></div>
<span class="ws-status disconnected" id="ws-status">Disconnected</span>
</div>
<div class="clients-grid" id="clients-grid">
<div class="empty-state"><h2>No frames connected</h2><p>Open Frambe on a tablet or screen to see it here</p></div>
</div>
<script>
var ws=null, clientsData={}, albumsCache=[], peopleCache=[];
function connect() {
var proto = location.protocol==='https:'?'wss:':'ws:';
ws = new WebSocket(proto+'//'+location.host+'/ws');
ws.onopen = function(){ document.getElementById('ws-status').textContent='Connected'; document.getElementById('ws-status').className='ws-status connected'; ws.send(JSON.stringify({type:'register',role:'admin'})); loadAlbumsAndPeople(); };
ws.onmessage = function(e){ var msg=JSON.parse(e.data); if(msg.type==='clientList'){clientsData={};msg.clients.forEach(function(c){clientsData[c.id]=c;});renderClients();} else if(msg.type==='clientUpdate'){clientsData[msg.clientId]=msg.client;renderClients();} };
ws.onclose = function(){ document.getElementById('ws-status').textContent='Disconnected'; document.getElementById('ws-status').className='ws-status disconnected'; setTimeout(connect,3000); };
}
async function loadAlbumsAndPeople(){ try{ var c=await(await fetch('/api/config')).json(); document.getElementById('version-text').textContent='v'+(c.version||'?'); albumsCache=await(await fetch('/api/albums')).json(); peopleCache=await(await fetch('/api/people')).json(); }catch(e){} }
function sendCommand(id,action,payload){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'adminCommand',targetId:id,action:action,payload:payload||{}})); }
function renameClient(id,name){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'renameClient',targetId:id,name:name})); }
function handleSourceChange(id,val){ if(!val)return; if(val==='random')sendCommand(id,'setSource',{source:'random'}); else if(val==='favorites')sendCommand(id,'setSource',{source:'favorites'}); else if(val.startsWith('album:'))sendCommand(id,'setSource',{source:'album',albumId:val.substring(6)}); else if(val.startsWith('person:'))sendCommand(id,'setSource',{source:'person',personId:val.substring(7)}); }
function esc(s){ var d=document.createElement('div');d.appendChild(document.createTextNode(s||''));return d.innerHTML; }
function renderClients(){
var grid=document.getElementById('clients-grid'),ids=Object.keys(clientsData);
if(!ids.length){grid.innerHTML='<div class="empty-state"><h2>No frames connected</h2><p>Open Frambe on a tablet or screen to see it here</p></div>';return;}
var html='';
ids.forEach(function(id){ var c=clientsData[id],sc=c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online',cfg=c.config||{};
html+='<div class="client-card">';
html+='<div class="client-header"><div><div class="client-name"><span class="status-dot '+sc+'"></span><input class="name-input" value="'+esc(c.name||'')+'" placeholder="'+esc(c.ip)+'" onchange="renameClient(\''+id+'\',this.value)"/></div><div class="client-ip">'+esc(c.ip)+'</div></div><div class="client-status">'+esc(c.status||'connected')+'</div></div>';
html+='<div class="controls">';
html+='<div class="control-row"><span class="control-label">Source</span><select onchange="handleSourceChange(\''+id+'\',this.value)"><option value="">-- Select --</option><option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albumsCache.forEach(function(a){html+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
peopleCache.filter(function(p){return p.name;}).forEach(function(p){html+='<option value="person:'+p.id+'">'+esc(p.name)+' (person)</option>';});
html+='</select></div><hr class="divider">';
html+='<div class="control-row"><span class="control-label">Playback</span><button class="btn success" onclick="sendCommand(\''+id+'\',\'start\')">Start</button><button class="btn" onclick="sendCommand(\''+id+'\',\'stop\')">Stop</button><button class="btn" onclick="sendCommand(\''+id+'\',\'next\')">Next</button><button class="btn" onclick="sendCommand(\''+id+'\',\'prev\')">Prev</button></div>';
html+='<div class="control-row"><span class="control-label">Power</span><button class="btn danger" onclick="sendCommand(\''+id+'\',\'sleep\')">Sleep</button><button class="btn success" onclick="sendCommand(\''+id+'\',\'wake\')">Wake</button><button class="btn" onclick="sendCommand(\''+id+'\',\'refresh\')">Refresh</button></div>';
html+='<hr class="divider">';
html+='<div class="control-row"><span class="control-label">Interval</span><input type="range" min="5" max="120" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="sendCommand(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="range-value">'+(cfg.slideshowInterval||30)+'s</span></div>';
html+='<div class="control-row"><span class="control-label">Clock</span><label class="toggle"><input type="checkbox" '+(cfg.showClock!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showClock:this.checked})"><span class="toggle-slider"></span></label><span class="control-label">Date</span><label class="toggle"><input type="checkbox" '+(cfg.showDate!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showDate:this.checked})"><span class="toggle-slider"></span></label></div>';
html+='<div class="control-row"><span class="control-label">EXIF</span><label class="toggle"><input type="checkbox" '+(cfg.showExif!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showExif:this.checked})"><span class="toggle-slider"></span></label><span class="control-label">Progress</span><label class="toggle"><input type="checkbox" '+(cfg.showProgress!==false?'checked':'')+' onchange="sendCommand(\''+id+'\',\'setConfig\',{showProgress:this.checked})"><span class="toggle-slider"></span></label></div>';
html+='</div></div>';
});
grid.innerHTML=html;
}
connect();
</script>
</body>
</html>