feat(dashboard): profile pic (More#3), sticky top nav (More#5), App-installs search+pagination (More#4)
- #3: profile avatar now shows the photo (avatarUrl) not just initials. - #5: header is position:sticky so the brand + profile stay visible while scrolling. - #4: App installs table gets a search box (user/platform/version/OS) and 10-per-page pagination with prev/next + count. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
::-webkit-scrollbar-thumb:hover{background:#aab6c8;}
|
::-webkit-scrollbar-thumb:hover{background:#aab6c8;}
|
||||||
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
||||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;}
|
||||||
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;}
|
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:100;} /* #5: keep the top bar + profile visible while scrolling */
|
||||||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||||||
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
||||||
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;} .brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
|
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;} .brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
|
||||||
@@ -77,7 +77,8 @@
|
|||||||
/* #12: report table scrolls horizontally on small screens instead of overflowing. */
|
/* #12: report table scrolls horizontally on small screens instead of overflowing. */
|
||||||
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;}
|
.table-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;}
|
||||||
.table-scroll table{min-width:560px;}
|
.table-scroll table{min-width:560px;}
|
||||||
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
|
.profile .pbtn .pav{position:relative;width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem;overflow:hidden}
|
||||||
|
.profile .pbtn .pav img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
|
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
|
||||||
.profile .pmenu.open{display:block}
|
.profile .pmenu.open{display:block}
|
||||||
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
|
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
|
||||||
@@ -107,7 +108,7 @@ function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p
|
|||||||
function profileHTML(u){
|
function profileHTML(u){
|
||||||
const display=u.name||u.email;
|
const display=u.name||u.email;
|
||||||
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
|
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
|
||||||
+ '<span class="pav">'+pEsc(initials(display))+'</span></button>'
|
+ '<span class="pav">'+pEsc(initials(display))+((u.avatarUrl||u.avatar_url)?'<img src="'+pEsc(u.avatarUrl||u.avatar_url)+'" alt="" onerror="this.remove()">':'')+'</span></button>'
|
||||||
+ '<div class="pmenu" id="pmenu">'
|
+ '<div class="pmenu" id="pmenu">'
|
||||||
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
|
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
|
||||||
+ '<a href="/home">Home</a>'
|
+ '<a href="/home">Home</a>'
|
||||||
@@ -244,8 +245,13 @@ async function dashboard(me) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card" id="installsCard">
|
<div class="card" id="installsCard">
|
||||||
<h2>App installs <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— who installed the desktop / mobile app</span></h2>
|
<h2>App installs <span class="muted" style="font-weight:400;font-size:.8rem;text-transform:none;letter-spacing:0">— who installed the desktop / mobile app</span></h2>
|
||||||
|
<input id="installSearch" placeholder="Search user, platform, version or OS…" autocomplete="off" style="width:100%;max-width:360px;padding:.5rem .7rem;border:1px solid #e6e9ef;border-radius:9px;margin:.2rem 0 .7rem;font-size:.9rem">
|
||||||
<div class="table-scroll"><table id="installs"><thead><tr><th>User</th><th>Platform</th><th>Version</th><th>OS</th><th>First seen</th><th>Last seen</th></tr></thead><tbody></tbody></table></div>
|
<div class="table-scroll"><table id="installs"><thead><tr><th>User</th><th>Platform</th><th>Version</th><th>OS</th><th>First seen</th><th>Last seen</th></tr></thead><tbody></tbody></table></div>
|
||||||
<p id="installsEmpty" class="muted" style="margin-top:.6rem;display:none">No installs recorded yet.</p>
|
<p id="installsEmpty" class="muted" style="margin-top:.6rem;display:none">No installs recorded yet.</p>
|
||||||
|
<div id="installsPager" style="display:none;align-items:center;justify-content:space-between;margin-top:.7rem;gap:.5rem">
|
||||||
|
<span class="muted" id="installsCount" style="font-size:.82rem"></span>
|
||||||
|
<span style="display:flex;gap:.4rem;align-items:center"><button class="mini" id="installsPrev">Prev</button><span id="installsPage" class="muted" style="font-size:.82rem"></span><button class="mini" id="installsNext">Next</button></span>
|
||||||
|
</div>
|
||||||
</div>` : ''}`);
|
</div>` : ''}`);
|
||||||
document.getElementById('fApply').onclick = loadReport;
|
document.getElementById('fApply').onclick = loadReport;
|
||||||
document.getElementById('fExcel').onclick = exportExcel;
|
document.getElementById('fExcel').onclick = exportExcel;
|
||||||
@@ -260,13 +266,31 @@ async function dashboard(me) {
|
|||||||
await loadInstalls();
|
await loadInstalls();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let _installs=[], _installQ='', _installPage=0; const INSTALL_PAGE=10;
|
||||||
async function loadInstalls(){
|
async function loadInstalls(){
|
||||||
let rows=[]; try{ rows=await fetch('/api/v1/admin/installs').then(r=>r.json()); }catch(_){}
|
try{ _installs=await fetch('/api/v1/admin/installs').then(r=>r.json()); }catch(_){ _installs=[]; }
|
||||||
|
if(!Array.isArray(_installs)) _installs=[];
|
||||||
|
const s=document.getElementById('installSearch');
|
||||||
|
if(s && !s._w){ s._w=1; s.oninput=()=>{ _installQ=s.value.trim().toLowerCase(); _installPage=0; renderInstalls(); }; }
|
||||||
|
_installPage=0; renderInstalls();
|
||||||
|
}
|
||||||
|
function renderInstalls(){
|
||||||
const tb=document.querySelector('#installs tbody'); if(!tb) return;
|
const tb=document.querySelector('#installs tbody'); if(!tb) return;
|
||||||
const empty=document.getElementById('installsEmpty');
|
const empty=document.getElementById('installsEmpty'), pager=document.getElementById('installsPager');
|
||||||
if(!Array.isArray(rows)||!rows.length){ tb.innerHTML=''; if(empty) empty.style.display='block'; return; }
|
const q=_installQ;
|
||||||
|
const filtered=_installs.filter(r=>!q||[r.user_email,r.platform,r.app_version,r.os].some(v=>String(v||'').toLowerCase().includes(q)));
|
||||||
|
if(!filtered.length){ tb.innerHTML=''; if(empty){ empty.style.display='block'; empty.textContent=q?'No installs match your search.':'No installs recorded yet.'; } if(pager) pager.style.display='none'; return; }
|
||||||
if(empty) empty.style.display='none';
|
if(empty) empty.style.display='none';
|
||||||
tb.innerHTML=rows.map(r=>`<tr><td>${esc(r.user_email||'—')}</td><td>${esc(r.platform||'—')}</td><td>${esc(r.app_version||'—')}</td><td>${esc(r.os||'—')}</td><td>${fmtTs(r.first_seen)}</td><td>${fmtTs(r.last_seen)}</td></tr>`).join('');
|
const pages=Math.ceil(filtered.length/INSTALL_PAGE); if(_installPage>=pages) _installPage=pages-1;
|
||||||
|
const page=filtered.slice(_installPage*INSTALL_PAGE, _installPage*INSTALL_PAGE+INSTALL_PAGE);
|
||||||
|
tb.innerHTML=page.map(r=>`<tr><td>${esc(r.user_email||'—')}</td><td>${esc(r.platform||'—')}</td><td>${esc(r.app_version||'—')}</td><td>${esc(r.os||'—')}</td><td>${fmtTs(r.first_seen)}</td><td>${fmtTs(r.last_seen)}</td></tr>`).join('');
|
||||||
|
if(pager){ pager.style.display=pages>1?'flex':'none';
|
||||||
|
const cnt=document.getElementById('installsCount'); if(cnt) cnt.textContent=filtered.length+' install'+(filtered.length===1?'':'s');
|
||||||
|
const pg=document.getElementById('installsPage'); if(pg) pg.textContent='Page '+(_installPage+1)+' / '+pages;
|
||||||
|
const prev=document.getElementById('installsPrev'), next=document.getElementById('installsNext');
|
||||||
|
if(prev){ prev.disabled=_installPage<=0; prev.onclick=()=>{ if(_installPage>0){ _installPage--; renderInstalls(); } }; }
|
||||||
|
if(next){ next.disabled=_installPage>=pages-1; next.onclick=()=>{ if(_installPage<pages-1){ _installPage++; renderInstalls(); } }; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Integrations: API keys + webhooks (admin) ----------
|
// ---------- Integrations: API keys + webhooks (admin) ----------
|
||||||
|
|||||||
Reference in New Issue
Block a user