feat: persistent client list with online/offline status, REST API reference panel with copy buttons
This commit is contained in:
+99
-5
@@ -16,12 +16,16 @@
|
||||
.status-dot.online { background: #4ade80; }
|
||||
.status-dot.sleeping { background: #fbbf24; }
|
||||
.status-dot.playing { background: #60a5fa; }
|
||||
.status-dot.offline { background: #666; }
|
||||
.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-card.offline { opacity: 0.5; }
|
||||
.client-card.offline:hover { opacity: 0.7; }
|
||||
.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-meta { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.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; }
|
||||
@@ -36,6 +40,7 @@
|
||||
.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); }
|
||||
.btn.small { font-size: 0.7rem; padding: 3px 8px; }
|
||||
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; }
|
||||
@@ -53,6 +58,22 @@
|
||||
.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; }
|
||||
.section-header { font-size: 1.2rem; font-weight: 300; margin: 2rem 0 1rem 0; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); display: flex; align-items: center; justify-content: space-between; cursor: pointer; }
|
||||
.section-header:hover { color: #fff; }
|
||||
.section-header .toggle-arrow { font-size: 0.8rem; color: #666; transition: transform 0.2s; }
|
||||
.section-header .toggle-arrow.open { transform: rotate(90deg); }
|
||||
.api-ref { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; }
|
||||
.api-ref h3 { font-size: 0.9rem; font-weight: 500; color: #a5b4fc; margin-bottom: 0.5rem; }
|
||||
.api-ref .method { display: inline-block; font-size: 0.7rem; font-weight: 600; padding: 2px 6px; border-radius: 4px; margin-right: 6px; }
|
||||
.api-ref .method.get { background: rgba(34,197,94,0.2); color: #86efac; }
|
||||
.api-ref .method.post { background: rgba(59,130,246,0.2); color: #93c5fd; }
|
||||
.api-ref .method.delete { background: rgba(239,68,68,0.2); color: #fca5a5; }
|
||||
.api-ref .endpoint { font-family: monospace; font-size: 0.85rem; color: #ccc; }
|
||||
.api-ref p { font-size: 0.8rem; color: #888; margin: 0.4rem 0; }
|
||||
.api-ref pre { background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 10px 12px; font-size: 0.75rem; color: #ccc; overflow-x: auto; margin-top: 0.5rem; white-space: pre-wrap; word-break: break-all; position: relative; }
|
||||
.copy-btn { position: absolute; top: 6px; right: 6px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; color: #aaa; font-size: 0.65rem; padding: 2px 6px; cursor: pointer; }
|
||||
.copy-btn:hover { background: rgba(255,255,255,0.15); color: #fff; }
|
||||
.offline-msg { font-size: 0.8rem; color: #888; text-align: center; padding: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -67,10 +88,71 @@
|
||||
<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>
|
||||
<div class="section-header" onclick="toggleApiRef()">REST API Reference <span class="toggle-arrow" id="api-arrow">▶</span></div>
|
||||
<div id="api-ref-section" style="display:none">
|
||||
<div class="api-ref">
|
||||
<h3><span class="method get">GET</span> <span class="endpoint">/api/clients</span></h3>
|
||||
<p>List all known frames with their status, IP, name, and config.</p>
|
||||
<pre id="curl-list">curl -s -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients<span class="copy-btn" onclick="copyCmd('curl-list')">Copy</span></pre>
|
||||
</div>
|
||||
<div class="api-ref">
|
||||
<h3><span class="method post">POST</span> <span class="endpoint">/api/clients/:id/command</span></h3>
|
||||
<p>Send a command to a specific frame. Available actions:</p>
|
||||
<p><strong>start</strong> — Start slideshow <strong>stop</strong> — Stop slideshow <strong>next</strong> — Next photo <strong>prev</strong> — Previous photo</p>
|
||||
<p><strong>sleep</strong> — Put frame to sleep <strong>wake</strong> — Wake frame <strong>refresh</strong> — Reload photos from source</p>
|
||||
<p><strong>setSource</strong> — Change photo source (payload: <code>{"source":"album","albumId":"UUID"}</code> or <code>{"source":"random"}</code> or <code>{"source":"favorites"}</code> or <code>{"source":"person","personId":"UUID"}</code>)</p>
|
||||
<p><strong>setConfig</strong> — Update settings (payload: <code>{"slideshowInterval":30,"showClock":true,...}</code>)</p>
|
||||
<pre id="curl-next">curl -s -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"action":"next"}' http://YOUR_HOST:3030/api/clients/CLIENT_ID/command<span class="copy-btn" onclick="copyCmd('curl-next')">Copy</span></pre>
|
||||
<pre id="curl-source">curl -s -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"action":"setSource","payload":{"source":"album","albumId":"YOUR_ALBUM_UUID"}}' http://YOUR_HOST:3030/api/clients/CLIENT_ID/command<span class="copy-btn" onclick="copyCmd('curl-source')">Copy</span></pre>
|
||||
</div>
|
||||
<div class="api-ref">
|
||||
<h3><span class="method delete">DELETE</span> <span class="endpoint">/api/clients/:id</span></h3>
|
||||
<p>Remove a frame from the registry (clears it from the client list).</p>
|
||||
<pre id="curl-delete">curl -s -X DELETE -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients/CLIENT_ID<span class="copy-btn" onclick="copyCmd('curl-delete')">Copy</span></pre>
|
||||
</div>
|
||||
<div class="api-ref">
|
||||
<h3>Home Assistant — rest_command examples</h3>
|
||||
<pre id="ha-yaml">rest_command:
|
||||
frambe_next_photo:
|
||||
url: "http://YOUR_HOST:3030/api/clients/CLIENT_ID/command"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer YOUR_TOKEN"
|
||||
Content-Type: "application/json"
|
||||
payload: '{"action": "next"}'
|
||||
|
||||
frambe_sleep:
|
||||
url: "http://YOUR_HOST:3030/api/clients/CLIENT_ID/command"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer YOUR_TOKEN"
|
||||
Content-Type: "application/json"
|
||||
payload: '{"action": "sleep"}'
|
||||
|
||||
frambe_wake:
|
||||
url: "http://YOUR_HOST:3030/api/clients/CLIENT_ID/command"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer YOUR_TOKEN"
|
||||
Content-Type: "application/json"
|
||||
payload: '{"action": "wake"}'
|
||||
|
||||
frambe_set_album:
|
||||
url: "http://YOUR_HOST:3030/api/clients/CLIENT_ID/command"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: "Bearer YOUR_TOKEN"
|
||||
Content-Type: "application/json"
|
||||
payload: '{"action": "setSource", "payload": {"source": "album", "albumId": "YOUR_ALBUM_UUID"}}'<span class="copy-btn" onclick="copyCmd('ha-yaml')">Copy</span></pre>
|
||||
</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 toggleApiRef() { var s = document.getElementById('api-ref-section'); var a = document.getElementById('api-arrow'); if (s.style.display === 'none') { s.style.display = 'block'; a.classList.add('open'); } else { s.style.display = 'none'; a.classList.remove('open'); } }
|
||||
function copyCmd(id) { var el = document.getElementById(id); var text = el.textContent.replace('Copy', '').replace('Copied!', '').trim(); navigator.clipboard.writeText(text).then(function() { var btn = el.querySelector('.copy-btn'); if (btn) { btn.textContent = 'Copied!'; setTimeout(function() { btn.textContent = 'Copy'; }, 1500); } }); }
|
||||
function timeAgo(iso) { if (!iso) return ''; var diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); if (diff < 60) return diff + 's ago'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; }
|
||||
function connect() {
|
||||
var proto = location.protocol==='https:'?'wss:':'ws:';
|
||||
ws = new WebSocket(proto+'//'+location.host+'/ws');
|
||||
@@ -81,15 +163,25 @@
|
||||
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 removeClient(id,name){ if(confirm('Remove "'+(name||id)+'" from the client list?')){ if(ws&&ws.readyState===WebSocket.OPEN) ws.send(JSON.stringify({type:'removeClient',targetId:id})); } }
|
||||
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;}
|
||||
if(!ids.length){grid.innerHTML='<div class="empty-state"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div>';return;}
|
||||
ids.sort(function(a,b){ var ao=clientsData[a].status==='offline'?1:0, bo=clientsData[b].status==='offline'?1:0; return ao-bo; });
|
||||
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>';
|
||||
ids.forEach(function(id){ var c=clientsData[id], isOffline=c.status==='offline';
|
||||
var sc=isOffline?'offline':c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online';
|
||||
var cfg=c.config||{};
|
||||
html+='<div class="client-card'+(isOffline?' offline':'')+'">';
|
||||
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>';
|
||||
html+='<div class="client-meta"><div class="client-status">'+esc(c.status||'unknown')+'</div>';
|
||||
if(isOffline) html+='<button class="btn small danger" onclick="removeClient(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Remove</button>';
|
||||
html+='</div></div>';
|
||||
if(isOffline){
|
||||
html+='<div class="offline-msg">Last seen: '+timeAgo(c.lastSeen)+'</div>';
|
||||
} else {
|
||||
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>';});
|
||||
@@ -101,7 +193,9 @@
|
||||
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>';
|
||||
html+='</div>';
|
||||
}
|
||||
html+='</div>';
|
||||
});
|
||||
grid.innerHTML=html;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user