fix: re-upload admin dashboard with correct encoding

This commit is contained in:
jessikitty committed 2026-05-26 15:11:59 +10:00
1 parent 1d209b5daa
commit e770332032
1 file changed
+111 -1
+111 -1
View File
@@ -1 +1,111 @@
PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0iVVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MS4wIj4KICA8dGl0bGU+RnJhbWJlIEFkbWluPC90aXRsZT4KICA8c3R5bGU+CiAgICAqLCAqOjpiZWZvcmUsICo6OmFmdGVyIHsgbWFyZ2luOiAwOyBwYWRkaW5nOiAwOyBib3gtc2l6aW5nOiBib3JkZXItYm94OyB9CiAgICBib2R5IHsgYmFja2dyb3VuZDogIzBmMGYxYTsgY29sb3I6ICNlMGUwZTA7IGZvbnQtZmFtaWx5OiAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICdTZWdvZSBVSScsIFJvYm90bywgc2Fucy1zZXJpZjsgcGFkZGluZzogMS41cmVtOyBtaW4taGVpZ2h0OiAxMDB2aDsgfQogICAgaDEgeyBmb250LXNpemU6IDEuNnJlbTsgZm9udC13ZWlnaHQ6IDMwMDsgbWFyZ2luLWJvdHRvbTogMC4yNXJlbTsgfQogICAgLmhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMXJlbTsgbWFyZ2luLWJvdHRvbTogMS41cmVtOyBib3JkZXItYm90dG9tOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwwLjEpOyBwYWRkaW5nLWJvdHRvbTogMXJlbTsgfQogICAgLmhlYWRlciBpbWcgeyB3aWR0aDogNDhweDsgaGVpZ2h0OiA0OHB4OyBib3JkZXItcmFkaXVzOiAxMHB4OyB9CiAgICAuaGVhZGVyIC52ZXJzaW9uIHsgZm9udC1zaXplOiAwLjhyZW07IGNvbG9yOiAjNjY2OyB9CiAgICAuaGVhZGVyLXJpZ2h0IHsgbWFyZ2luLWxlZnQ6IGF1dG87IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMC43NXJlbTsgfQogICAgLnN0YXR1cy1kb3QgeyBkaXNwbGF5OiBpbmxpbmUtYmxvY2s7IHdpZHRoOiA4cHg7IGhlaWdodDogOHB4OyBib3JkZXItcmFkaXVzOiA1MCU7IG1hcmdpbi1yaWdodDogNnB4OyB9CiAgICAuc3RhdHVzLWRvdC5vbmxpbmUgeyBiYWNrZ3JvdW5kOiAjNGFkZTgwOyB9CiAgICAuc3RhdHVzLWRvdC5zbGVlcGluZyB7IGJhY2tncm91bmQ6ICNmYmJmMjQ7IH0KICAgIC5zdGF0dXMtZG90LnBsYXlpbmcgeyBiYWNrZ3JvdW5kOiAjNjBhNWZhOyB9CiAgICAuY2xpZW50cy1ncmlkIHsgZGlzcGxheTogZ3JpZDsgZ3JpZC10ZW1wbGF0ZS1jb2x1bW5zOiByZXBlYXQoYXV0by1maWxsLCBtaW5tYXgoMzQwcHgsIDFmcikpOyBnYXA6IDFyZW07IH0KICAgIC5jbGllbnQtY2FyZCB7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNCk7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4wOCk7IGJvcmRlci1yYWRpdXM6IDEycHg7IHBhZGRpbmc6IDEuMjVyZW07IHRyYW5zaXRpb246IGFsbCAwLjJzOyB9CiAgICAuY2xpZW50LWNhcmQ6aG92ZXIgeyBib3JkZXItY29sb3I6IHJnYmEoOTksMTAyLDI0MSwwLjMpOyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMDYpOyB9CiAgICAuY2xpZW50LWhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGp1c3RpZnktY29udGVudDogc3BhY2UtYmV0d2VlbjsgYWxpZ24taXRlbXM6IGNlbnRlcjsgbWFyZ2luLWJvdHRvbTogMXJlbTsgfQogICAgLmNsaWVudC1uYW1lIHsgZm9udC1zaXplOiAxLjFyZW07IGZvbnQtd2VpZ2h0OiA1MDA7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMC41cmVtOyB9CiAgICAuY2xpZW50LWlwIHsgZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogIzg4ODsgZm9udC1mYW1pbHk6IG1vbm9zcGFjZTsgfQogICAgLmNsaWVudC1zdGF0dXMgeyBmb250LXNpemU6IDAuOHJlbTsgY29sb3I6ICNhYWE7IHRleHQtdHJhbnNmb3JtOiBjYXBpdGFsaXplOyB9CiAgICAubmFtZS1pbnB1dCB7IGJhY2tncm91bmQ6IHRyYW5zcGFyZW50OyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMTUpOyBib3JkZXItcmFkaXVzOiA2cHg7IGNvbG9yOiAjZmZmOyBmb250LXNpemU6IDAuOXJlbTsgcGFkZGluZzogNHB4IDhweDsgd2lkdGg6IDE0MHB4OyB9CiAgICAubmFtZS1pbnB1dDpmb2N1cyB7IG91dGxpbmU6IG5vbmU7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgLmNvbnRyb2xzIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAwLjc1cmVtOyB9CiAgICAuY29udHJvbC1yb3cgeyBkaXNwbGF5OiBmbGV4OyBhbGlnbi1pdGVtczogY2VudGVyOyBnYXA6IDAuNzVyZW07IGZsZXgtd3JhcDogd3JhcDsgfQogICAgLmNvbnRyb2wtbGFiZWwgeyBmb250LXNpemU6IDAuOHJlbTsgY29sb3I6ICM4ODg7IG1pbi13aWR0aDogNjBweDsgfQogICAgLmJ0biB7IHBhZGRpbmc6IDZweCAxNHB4OyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMTUpOyBib3JkZXItcmFkaXVzOiA4cHg7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNik7IGNvbG9yOiAjZTBlMGUwOyBmb250LXNpemU6IDAuOHJlbTsgY3Vyc29yOiBwb2ludGVyOyB0cmFuc2l0aW9uOiBhbGwgMC4xNXM7IHdoaXRlLXNwYWNlOiBub3dyYXA7IH0KICAgIC5idG46aG92ZXIgeyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMTIpOyBib3JkZXItY29sb3I6IHJnYmEoMjU1LDI1NSwyNTUsMC4yNSk7IH0KICAgIC5idG4uZGFuZ2VyIHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4xNSk7IGJvcmRlci1jb2xvcjogI2VmNDQ0NDsgY29sb3I6ICNmY2E1YTU7IH0KICAgIC5idG4uZGFuZ2VyOmhvdmVyIHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4zKTsgfQogICAgLmJ0bi5zdWNjZXNzIHsgYmFja2dyb3VuZDogcmdiYSgzNCwxOTcsOTQsMC4xNSk7IGJvcmRlci1jb2xvcjogIzIyYzU1ZTsgY29sb3I6ICM4NmVmYWM7IH0KICAgIC5idG4uc3VjY2Vzczpob3ZlciB7IGJhY2tncm91bmQ6IHJnYmEoMzQsMTk3LDk0LDAuMyk7IH0KICAgIC5idG4ubG9nb3V0IHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4xKTsgYm9yZGVyLWNvbG9yOiByZ2JhKDIzOSw2OCw2OCwwLjMpOyBjb2xvcjogI2ZjYTVhNTsgZm9udC1zaXplOiAwLjc1cmVtOyBwYWRkaW5nOiA0cHggMTJweDsgfQogICAgLmJ0bi5sb2dvdXQ6aG92ZXIgeyBiYWNrZ3JvdW5kOiByZ2JhKDIzOSw2OCw2OCwwLjI1KTsgfQogICAgc2VsZWN0IHsgcGFkZGluZzogNnB4IDEwcHg7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4xNSk7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogcmdiYSgyNTUsMjU1LDI1NSwwLjA2KTsgY29sb3I6ICNlMGUwZTA7IGZvbnQtc2l6ZTogMC44cmVtOyBjdXJzb3I6IHBvaW50ZXI7IG1heC13aWR0aDogMjAwcHg7IH0KICAgIHNlbGVjdDpmb2N1cyB7IG91dGxpbmU6IG5vbmU7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgb3B0aW9uIHsgYmFja2dyb3VuZDogIzFhMWEyZTsgY29sb3I6ICNlMGUwZTA7IH0KICAgIGlucHV0W3R5cGU9cmFuZ2VdIHsgd2lkdGg6IDEyMHB4OyBhY2NlbnQtY29sb3I6ICM2MzY2ZjE7IH0KICAgIC5yYW5nZS12YWx1ZSB7IGZvbnQtc2l6ZTogMC44cmVtOyBjb2xvcjogI2FhYTsgbWluLXdpZHRoOiAzMHB4OyB9CiAgICAudG9nZ2xlIHsgcG9zaXRpb246IHJlbGF0aXZlOyB3aWR0aDogNDBweDsgaGVpZ2h0OiAyMnB4OyBjdXJzb3I6IHBvaW50ZXI7IH0KICAgIC50b2dnbGUgaW5wdXQgeyBkaXNwbGF5OiBub25lOyB9CiAgICAudG9nZ2xlLXNsaWRlciB7IHBvc2lLine truncated
<!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; }
.header-right { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
.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); }
.btn.logout { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); color: #fca5a5; font-size: 0.75rem; padding: 4px 12px; }
.btn.logout:hover { background: rgba(239,68,68,0.25); }
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>
<div class="header-right">
<span class="ws-status disconnected" id="ws-status">Disconnected</span>
<button class="btn logout" id="logout-btn" style="display:none" onclick="doLogout()">Logout</button>
</div>
</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=[], authEnabled=false;
(async function checkAuth() { try { var r = await (await fetch('/api/auth/status')).json(); authEnabled = r.authEnabled; if (authEnabled) document.getElementById('logout-btn').style.display = ''; } catch(e) {} })();
async function doLogout() { try { await fetch('/api/auth/logout', { method: 'POST' }); } catch(e) {} window.location.href = '/admin/login'; }
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>