feat: comprehensive admin dashboard with HA YAML generator, online/offline badges, device info

This commit is contained in:
2026-06-02 10:44:59 +10:00
parent 243396c93a
commit c067ff237e
+170 -183
View File
@@ -5,201 +5,188 @@
<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; }
.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; }
.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); }
.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; }
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; }
.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; }
*,*::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:.25rem}
.header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;border-bottom:1px solid rgba(255,255,255,.1);padding-bottom:1rem}
.header img{width:48px;height:48px;border-radius:10px}
.header .version{font-size:.8rem;color:#666}
.header-right{margin-left:auto;display:flex;align-items:center;gap:.75rem}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.dot.online{background:#4ade80} .dot.sleeping{background:#fbbf24} .dot.playing{background:#60a5fa} .dot.offline{background:#555}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(370px,1fr));gap:1rem}
.card{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:1.25rem;transition:all .2s}
.card:hover{border-color:rgba(99,102,241,.3);background:rgba(255,255,255,.06)}
.card.offline{opacity:.45}.card.offline:hover{opacity:.65}
.card-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
.card-name{font-size:1.1rem;font-weight:500;display:flex;align-items:center;gap:.5rem}
.card-ip{font-size:.72rem;color:#777;font-family:monospace}
.card-meta{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.badge{font-size:.65rem;padding:2px 8px;border-radius:10px;text-transform:uppercase;font-weight:600;letter-spacing:.3px}
.badge.online{background:rgba(74,222,128,.15);color:#86efac} .badge.offline{background:rgba(255,255,255,.06);color:#777}
.badge.playing{background:rgba(96,165,250,.15);color:#93c5fd} .badge.sleeping{background:rgba(251,191,36,.15);color:#fcd34d}
.name-input{background:transparent;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:.85rem;padding:3px 7px;width:130px}
.name-input:focus{outline:none;border-color:#6366f1}
.info-row{font-size:.72rem;color:#666;margin-bottom:.75rem;display:flex;gap:1rem;flex-wrap:wrap}
.info-row span{white-space:nowrap}
.controls{display:flex;flex-direction:column;gap:.6rem}
.crow{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
.clbl{font-size:.78rem;color:#777;min-width:55px}
.btn{padding:5px 12px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem;cursor:pointer;transition:all .12s;white-space:nowrap}
.btn:hover{background:rgba(255,255,255,.1);border-color:rgba(255,255,255,.2)}
.btn.red{background:rgba(239,68,68,.12);border-color:rgba(239,68,68,.4);color:#fca5a5}.btn.red:hover{background:rgba(239,68,68,.25)}
.btn.grn{background:rgba(34,197,94,.12);border-color:rgba(34,197,94,.4);color:#86efac}.btn.grn:hover{background:rgba(34,197,94,.25)}
.btn.blue{background:rgba(99,102,241,.12);border-color:rgba(99,102,241,.4);color:#a5b4fc}.btn.blue:hover{background:rgba(99,102,241,.25)}
.btn.sm{font-size:.68rem;padding:2px 7px}
.btn.logout{background:rgba(239,68,68,.08);border-color:rgba(239,68,68,.25);color:#fca5a5;font-size:.72rem;padding:3px 10px}
select{padding:5px 8px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem;cursor:pointer;max-width:200px}
select:focus{outline:none;border-color:#6366f1}
option{background:#1a1a2e;color:#e0e0e0}
input[type=range]{width:110px;accent-color:#6366f1}
.rval{font-size:.78rem;color:#999;min-width:28px}
.tgl{position:relative;width:36px;height:20px;cursor:pointer}.tgl input{display:none}
.tgl-s{position:absolute;inset:0;background:rgba(255,255,255,.08);border-radius:10px;transition:.2s}
.tgl-s::before{content:'';position:absolute;width:14px;height:14px;left:3px;bottom:3px;background:#777;border-radius:50%;transition:.2s}
.tgl input:checked+.tgl-s{background:rgba(99,102,241,.35)}.tgl input:checked+.tgl-s::before{transform:translateX(16px);background:#a5b4fc}
.empty{text-align:center;padding:4rem 2rem;color:#555}
.empty h2{font-size:1.1rem;font-weight:400;margin-bottom:.4rem;color:#777}
.ws-pill{font-size:.72rem;padding:3px 9px;border-radius:20px}
.ws-pill.on{background:rgba(34,197,94,.12);color:#86efac}.ws-pill.off{background:rgba(239,68,68,.12);color:#fca5a5}
.divider{border:none;border-top:1px solid rgba(255,255,255,.05);margin:.4rem 0}
.offline-msg{font-size:.78rem;color:#666;text-align:center;padding:.6rem}
.sec-head{font-size:1.15rem;font-weight:300;margin:2rem 0 .75rem;padding-top:1.25rem;border-top:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;cursor:pointer}
.sec-head:hover{color:#fff}
.sec-head .arr{font-size:.75rem;color:#555;transition:transform .2s}.sec-head .arr.open{transform:rotate(90deg)}
.api-card{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.05);border-radius:10px;padding:1rem;margin-bottom:.6rem}
.api-card h3{font-size:.85rem;font-weight:500;color:#a5b4fc;margin-bottom:.4rem}
.mtd{display:inline-block;font-size:.65rem;font-weight:700;padding:1px 5px;border-radius:3px;margin-right:5px}
.mtd.get{background:rgba(34,197,94,.18);color:#86efac}.mtd.post{background:rgba(59,130,246,.18);color:#93c5fd}.mtd.del{background:rgba(239,68,68,.18);color:#fca5a5}
.ep{font-family:monospace;font-size:.82rem;color:#bbb}
.api-card p{font-size:.75rem;color:#777;margin:.3rem 0}
pre{background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.06);border-radius:7px;padding:8px 10px;font-size:.72rem;color:#bbb;overflow-x:auto;margin-top:.4rem;white-space:pre-wrap;word-break:break-all;position:relative}
.cpb{position:absolute;top:5px;right:5px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:3px;color:#999;font-size:.6rem;padding:1px 5px;cursor:pointer}
.cpb:hover{background:rgba(255,255,255,.12);color:#fff}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:none;align-items:center;justify-content:center;z-index:100;padding:1rem}
.modal-overlay.open{display:flex}
.modal{background:#16162a;border:1px solid rgba(255,255,255,.1);border-radius:14px;padding:1.5rem;max-width:700px;width:100%;max-height:85vh;overflow-y:auto}
.modal h2{font-size:1.2rem;font-weight:400;margin-bottom:.25rem}
.modal .sub{font-size:.78rem;color:#777;margin-bottom:1rem}
.modal-close{float:right;background:none;border:none;color:#777;font-size:1.2rem;cursor:pointer;padding:4px 8px}.modal-close:hover{color:#fff}
.yaml-block{background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:12px;font-size:.72rem;color:#ccc;white-space:pre;overflow-x:auto;line-height:1.5;position:relative;margin-bottom:.75rem}
.input-row{display:flex;gap:.75rem;margin-bottom:1rem;flex-wrap:wrap}
.input-row label{font-size:.78rem;color:#888;display:block;margin-bottom:.25rem}
.input-row input{padding:6px 10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#ddd;font-size:.82rem;width:100%}
.input-row input:focus{outline:none;border-color:#6366f1}
.input-row .field{flex:1;min-width:140px}
</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><h1>Frambe Admin</h1><span class="version" id="ver">Connecting...</span></div>
<div class="header-right">
<span class="ws-status disconnected" id="ws-status">Disconnected</span>
<span class="ws-pill off" id="ws-pill">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 class="grid" id="grid"><div class="empty"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div></div>
<div class="sec-head" onclick="toggle('api-sec','api-arr')">REST API Reference <span class="arr" id="api-arr">&#9654;</span></div>
<div id="api-sec" style="display:none">
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/clients</span></h3><p>List all known frames with status, IP, name, and config.</p><pre id="c-list">curl -s -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients<span class="cpb" onclick="cc('c-list')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd post">POST</span><span class="ep">/api/clients/:id/command</span></h3><p>Send a command to a frame. Actions: <code>start</code> <code>stop</code> <code>next</code> <code>prev</code> <code>sleep</code> <code>wake</code> <code>refresh</code> <code>setSource</code> <code>setConfig</code></p><pre id="c-cmd">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="cpb" onclick="cc('c-cmd')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd del">DELETE</span><span class="ep">/api/clients/:id</span></h3><p>Remove a frame from the registry.</p><pre id="c-del">curl -s -X DELETE -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients/CLIENT_ID<span class="cpb" onclick="cc('c-del')">Copy</span></pre></div>
</div>
<div class="section-header" onclick="toggleApiRef()">REST API Reference <span class="toggle-arrow" id="api-arrow">&#9654;</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 class="modal-overlay" id="ha-modal">
<div class="modal">
<button class="modal-close" onclick="closeModal()">&times;</button>
<h2>Home Assistant YAML Generator</h2>
<div class="sub" id="ha-client-name"></div>
<div class="input-row">
<div class="field"><label>Frambe Host</label><input id="ha-host" oninput="genYaml()" placeholder="frambe-server:3030"></div>
<div class="field"><label>API Token</label><input id="ha-token" oninput="genYaml()" placeholder="your-secret-token"></div>
<div class="field"><label>Client ID</label><input id="ha-cid" readonly></div>
</div>
<h3 style="font-size:.85rem;color:#a5b4fc;margin-bottom:.5rem">Generated YAML — paste into your configuration.yaml</h3>
<div class="yaml-block" id="ha-yaml"></div>
<button class="btn blue" onclick="cc('ha-yaml')" style="width:100%">Copy YAML to Clipboard</button>
</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');
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); };
<script>
var ws=null,D={},albums=[],people=[],auth=false;
(async function(){try{var r=await(await fetch('/api/auth/status')).json();auth=r.authEnabled;if(auth)document.getElementById('logout-btn').style.display='';}catch(e){}})();
async function doLogout(){try{await fetch('/api/auth/logout',{method:'POST'});}catch(e){}location.href='/admin/login';}
function toggle(sid,aid){var s=document.getElementById(sid),a=document.getElementById(aid);if(s.style.display==='none'){s.style.display='block';a.classList.add('open');}else{s.style.display='none';a.classList.remove('open');}}
function cc(id){var el=document.getElementById(id);var t=el.textContent.replace(/Copy|Copied!/g,'').trim();navigator.clipboard.writeText(t).then(function(){var b=el.querySelector('.cpb');if(b){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy';},1500);}});}
function ago(iso){if(!iso)return'never';var d=Math.floor((Date.now()-new Date(iso).getTime())/1000);if(d<5)return'just now';if(d<60)return d+'s ago';if(d<3600)return Math.floor(d/60)+'m ago';if(d<86400)return Math.floor(d/3600)+'h ago';return Math.floor(d/86400)+'d ago';}
function esc(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s||''));return d.innerHTML;}
function connect(){
var p=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(p+'//'+location.host+'/ws');
ws.onopen=function(){document.getElementById('ws-pill').textContent='Connected';document.getElementById('ws-pill').className='ws-pill on';ws.send(JSON.stringify({type:'register',role:'admin'}));loadMeta();};
ws.onmessage=function(e){var m=JSON.parse(e.data);if(m.type==='clientList'){D={};m.clients.forEach(function(c){D[c.id]=c;});render();}else if(m.type==='clientUpdate'){D[m.clientId]=m.client;render();}};
ws.onclose=function(){document.getElementById('ws-pill').textContent='Disconnected';document.getElementById('ws-pill').className='ws-pill off';setTimeout(connect,3000);};
}
async function loadMeta(){try{var c=await(await fetch('/api/config')).json();document.getElementById('ver').textContent='v'+(c.version||'?');albums=await(await fetch('/api/albums')).json();people=await(await fetch('/api/people')).json();}catch(e){}}
function cmd(id,a,pl){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'adminCommand',targetId:id,action:a,payload:pl||{}}));}
function ren(id,n){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'renameClient',targetId:id,name:n}));}
function rem(id,n){if(confirm('Remove "'+(n||id)+'" from client list?'))if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'removeClient',targetId:id}));}
function src(id,v){if(!v)return;if(v==='random')cmd(id,'setSource',{source:'random'});else if(v==='favorites')cmd(id,'setSource',{source:'favorites'});else if(v.indexOf('album:')===0)cmd(id,'setSource',{source:'album',albumId:v.substring(6)});else if(v.indexOf('person:')===0)cmd(id,'setSource',{source:'person',personId:v.substring(7)});}
var haClientId='';
function openHaModal(id,name){haClientId=id;document.getElementById('ha-client-name').textContent='Generating for: '+(name||id);document.getElementById('ha-cid').value=id;document.getElementById('ha-host').value=location.host;document.getElementById('ha-modal').classList.add('open');genYaml();}
function closeModal(){document.getElementById('ha-modal').classList.remove('open');}
function genYaml(){
var host=document.getElementById('ha-host').value||'frambe:3030';var token=document.getElementById('ha-token').value||'YOUR_TOKEN';var cid=haClientId;
var proto=location.protocol==='https:'?'https':'http';var url=proto+'://'+host+'/api/clients/'+cid+'/command';
var hdr=' headers:\n Authorization: "Bearer '+token+'"\n Content-Type: "application/json"';
var y='rest_command:\n # --- Frambe: '+(D[cid]?D[cid].name||cid:cid)+' ---\n\n';
var actions=[['next','Next photo'],['prev','Previous photo'],['start','Start slideshow'],['stop','Stop slideshow'],['sleep','Sleep display'],['wake','Wake display'],['refresh','Refresh photo source']];
actions.forEach(function(a){y+=' frambe_'+cid+'_'+a[0]+':\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "'+a[0]+'"}\'\n\n';});
y+=' frambe_'+cid+'_set_album:\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "setSource", "payload": {"source": "album", "albumId": "REPLACE_ALBUM_UUID"}}\'\n\n';
y+=' frambe_'+cid+'_set_random:\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "setSource", "payload": {"source": "random"}}\'\n\n';
y+=' frambe_'+cid+'_set_favorites:\n url: "'+url+'"\n method: POST\n'+hdr+'\n payload: \'{"action": "setSource", "payload": {"source": "favorites"}}\'';
document.getElementById('ha-yaml').textContent=y;
}
function render(){
var g=document.getElementById('grid'),ids=Object.keys(D);
if(!ids.length){g.innerHTML='<div class="empty"><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){return(D[a].status==='offline'?1:0)-(D[b].status==='offline'?1:0);});
var h='';
ids.forEach(function(id){var c=D[id],off=c.status==='offline';
var sc=off?'offline':c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online';var cfg=c.config||{};
h+='<div class="card'+(off?' offline':'')+'">';
h+='<div class="card-head"><div><div class="card-name"><span class="dot '+sc+'"></span><input class="name-input" value="'+esc(c.name||'')+'" placeholder="'+esc(c.ip)+'" onchange="ren(\''+id+'\',this.value)"/></div><div class="card-ip">'+esc(c.ip)+' &middot; ID: <strong>'+esc(id)+'</strong></div></div>';
h+='<div class="card-meta"><span class="badge '+sc+'">'+esc(c.status||'unknown')+'</span>';
if(off)h+='<button class="btn sm red" onclick="rem(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Remove</button>';
h+='</div></div>';
h+='<div class="info-row">';
if(c.firstSeen)h+='<span>First seen: '+ago(c.firstSeen)+'</span>';
h+='<span>Last seen: '+ago(c.lastSeen)+'</span>';
if(c.connectedAt&&!off)h+='<span>Connected: '+ago(c.connectedAt)+'</span>';
h+='</div>';
if(off){
h+='<div class="crow" style="justify-content:center"><button class="btn blue sm" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
} else {
h+='<div class="controls">';
h+='<div class="crow"><span class="clbl">Source</span><select onchange="src(\''+id+'\',this.value)"><option value="">-- Select --</option><option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albums.forEach(function(a){h+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
people.filter(function(p){return p.name;}).forEach(function(p){h+='<option value="person:'+p.id+'">'+esc(p.name)+'</option>';});
h+='</select></div><hr class="divider">';
h+='<div class="crow"><span class="clbl">Playback</span><button class="btn grn" onclick="cmd(\''+id+'\',\'start\')">Start</button><button class="btn" onclick="cmd(\''+id+'\',\'stop\')">Stop</button><button class="btn" onclick="cmd(\''+id+'\',\'next\')">Next</button><button class="btn" onclick="cmd(\''+id+'\',\'prev\')">Prev</button></div>';
h+='<div class="crow"><span class="clbl">Power</span><button class="btn red" onclick="cmd(\''+id+'\',\'sleep\')">Sleep</button><button class="btn grn" onclick="cmd(\''+id+'\',\'wake\')">Wake</button><button class="btn" onclick="cmd(\''+id+'\',\'refresh\')">Refresh</button></div>';
h+='<hr class="divider">';
h+='<div class="crow"><span class="clbl">Interval</span><input type="range" min="5" max="120" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="cmd(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="rval">'+(cfg.slideshowInterval||30)+'s</span></div>';
h+='<div class="crow"><span class="clbl">Clock</span><label class="tgl"><input type="checkbox" '+(cfg.showClock!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showClock:this.checked})"><span class="tgl-s"></span></label>';
h+='<span class="clbl">Date</span><label class="tgl"><input type="checkbox" '+(cfg.showDate!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showDate:this.checked})"><span class="tgl-s"></span></label>';
h+='<span class="clbl">EXIF</span><label class="tgl"><input type="checkbox" '+(cfg.showExif!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showExif:this.checked})"><span class="tgl-s"></span></label></div>';
h+='<hr class="divider">';
h+='<div class="crow"><button class="btn blue" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
h+='</div>';
}
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 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], 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>';});
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>';
}
html+='</div>';
});
grid.innerHTML=html;
}
connect();
</script>
h+='</div>';
});
g.innerHTML=h;
}
connect();
</script>
</body>
</html>