commit 5365efb93500a0b6b087a05b8739fb9066184396
parent ad8134a41238bcdaa4459177116f8a2c396daabe
Author: Amit Dutta <mail@amit.is-a.dev>
Date: Sat, 10 Oct 2026 05:53:48 +0530
Refactor admin interface styles and structure
Updated styles and HTML structure for admin interface, including adjustments for mobile responsiveness and improved element visibility.
Diffstat:
| M | app/admin.py | | | 105 | +++++++++++++++++++++++++++---------------------------------------------------- |
1 file changed, 36 insertions(+), 69 deletions(-)
diff --git a/app/admin.py b/app/admin.py
@@ -329,7 +329,6 @@ async def admin_delete(request: Request, body: PathBody):
if r["path"] == path:
r["status"] = "deleted"
return await _mutate_and_save()
-
_PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>CDN admin</title>
<style>
@@ -350,12 +349,14 @@ a{color:var(--blu);text-decoration:none}
.jump-bar a{white-space:nowrap;font-size:12px;background:var(--sf);border:1px solid var(--bd);padding:4px 10px;border-radius:14px;color:var(--tx)}
.jump-bar a.has-badge{border-color:var(--amb);color:var(--amb)}
-/* Standard Desktop Table Layout */
+/* Standard Horizontal Scroll Table Layout */
+.table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--bd);border-radius:8px;background:var(--sf);margin-bottom:10px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ln);vertical-align:middle}
+tr:last-child td{border-bottom:0}
th{color:var(--dim);font-weight:600}
.act{white-space:nowrap;text-align:right}.name{overflow-wrap:anywhere}
-button{font:inherit;font-size:12px;min-height:28px;padding:3px 8px;border-radius:6px;border:1px solid var(--bd);background:var(--sf);color:var(--tx);cursor:pointer}
+button{font:inherit;font-size:12px;min-height:28px;padding:3px 8px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--tx);cursor:pointer}
button.red{border-color:#da3633;color:var(--red)}button.green{border-color:#238636;color:var(--grn)}button:active{background:var(--ln)}
input{font:inherit;font-size:14px;padding:8px 10px;border-radius:6px;border:1px solid var(--bd);background:var(--bg);color:var(--tx);width:100%;max-width:420px}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow-wrap:break-word}.nw{white-space:nowrap}
@@ -363,16 +364,14 @@ input{font:inherit;font-size:14px;padding:8px 10px;border-radius:6px;border:1px
.badge{display:inline-block;padding:1px 5px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase}
.badge.ok{background:#1f3522;color:var(--grn)}.badge.bad{background:#3d1d1d;color:var(--red)}.badge.dim{background:var(--ln);color:var(--dim)}.badge.warn{background:#3a2d12;color:var(--amb)}
.banner{background:#3d1d1d;border:1px solid #da3633;padding:8px 12px;border-radius:6px;margin:10px 0;font-size:13px}
-.sec{border-radius:8px;scroll-margin-top:54px;margin-bottom:12px}
-.sec.attn{border-left:3px solid var(--amb);padding-left:8px}
/* Collapsible sections */
-details.box{background:var(--sf);border:1px solid var(--bd);border-radius:8px;padding:10px 12px;margin-bottom:10px;scroll-margin-top:54px}
-details.box summary{cursor:pointer;font-weight:600;font-size:13px;color:var(--tx);display:flex;align-items:center;justify-content:space-between;list-style:none}
+details.box{margin-bottom:10px;scroll-margin-top:54px}
+details.box summary{cursor:pointer;font-weight:600;font-size:13px;color:var(--tx);display:flex;align-items:center;justify-content:space-between;list-style:none;padding:4px 0}
details.box summary::-webkit-details-marker{display:none}
details.box summary::after{content:'+';font-size:14px;color:var(--dim)}
details.box[open] summary::after{content:'−'}
-details.box .content{margin-top:10px}
+details.box .content{margin-top:6px}
.who p{margin:6px 0 0;font-size:12px}
#login{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:20px}#login input{flex:1 1 220px}
@@ -382,39 +381,14 @@ details.box .content{margin-top:10px}
@media(min-width:721px){
.tiles{grid-template-columns:repeat(4,1fr)}
.jump-bar{display:none}
- details.box{background:transparent;border:0;padding:0}
details.box summary{display:none}
details.box .content{margin-top:0}
}
-
-/* Mobile-optimized Card View */
@media(max-width:720px){
body{padding:10px 10px 64px}
- table.cards,table.cards tbody{display:block;width:100%}
- table.cards tr{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:8px;padding:10px 12px;margin:0 0 8px}
- table.cards tr:first-child{display:none} /* hide table headers on mobile */
-
- /* Upload card row structure */
- table.cards.log-cards tr{display:grid;grid-template-areas:"name status" "meta meta" "act act";grid-template-columns:1fr auto;gap:4px 8px;align-items:center}
- table.cards.log-cards td{border:0;padding:0}
- table.cards.log-cards td.td-name{grid-area:name;font-weight:600;font-size:13px;overflow-wrap:anywhere}
- table.cards.log-cards td.td-status{grid-area:status;text-align:right}
- table.cards.log-cards td.td-meta{grid-area:meta;font-size:11px;color:var(--dim);display:flex;flex-wrap:wrap;gap:4px 8px;align-items:center}
- table.cards.log-cards td.td-meta span{display:inline-block}
- table.cards.log-cards td.td-act{grid-area:act;display:flex;gap:6px;margin-top:4px;padding-top:6px;border-top:1px solid var(--ln)}
- table.cards.log-cards td.td-act button{flex:1;min-height:30px}
-
- /* Other mobile cards (Bans, GitHub) */
- table.cards:not(.log-cards) td{display:flex;gap:8px;justify-content:space-between;align-items:baseline;border:0;padding:3px 0;text-align:right}
- table.cards:not(.log-cards) td::before{content:attr(data-l);color:var(--dim);font-size:12px;flex:none;text-align:left}
- table.cards:not(.log-cards) td.name{display:block;text-align:left;font-size:13px;font-weight:600}
- table.cards:not(.log-cards) td.name::before,table.cards:not(.log-cards) td.act::before{display:none}
- table.cards:not(.log-cards) td.act{display:flex;gap:6px;margin-top:4px;padding-top:6px;border-top:1px solid var(--ln)}
- table.cards:not(.log-cards) td.act button{flex:1;min-height:30px}
- table.cards td.empty{display:block;text-align:left;color:var(--dim)}
- table.kv tr{display:block;padding:4px 0;border-bottom:1px solid var(--ln)}
- table.kv td{display:block;border:0;padding:1px 0}
- table.kv td:first-child{color:var(--dim);font-size:11px}
+ /* Stagit-style strict single line horizontal scrolling on mobile */
+ th,td{white-space:nowrap}
+ .name,.mono{overflow-wrap:normal}
}
</style></head><body>
<header class="bar"><h1>CDN admin</h1><div class="links" id="nav" style="display:none"><a href="/stats">Stats</a><button onclick="logout()">Log out</button></div></header>
@@ -422,7 +396,7 @@ details.box .content{margin-top:10px}
<div id="app" style="display:none">
<div class="jump-bar" id="jumpbar"></div>
<div id="top"></div>
-<h2 id="uploads-h">Recent uploads</h2>
+<h2 id="uploads-h" style="margin-top:0">Recent uploads</h2>
<div id="qrow"><input id="q" type="search" placeholder="Search file name, link, IP, status..." autocomplete="off"><span id="qinfo" class="dim"></span></div>
<div id="uploads"></div>
<div id="rest"></div>
@@ -459,8 +433,7 @@ async function load(first){
}
}
const GH='/api/admin/gh';
-const cell=(l,v,c)=>`<td data-l="${l}"${c?` class="${c}"`:''}>${v}</td>`;
-const empty=(n,t)=>`<tr><td class="empty" colspan="${n}">${t}</td></tr>`;
+const empty=(n,t)=>`<tr><td class="empty dim" colspan="${n}">${t}</td></tr>`;
function logout(){sessionStorage.removeItem('t');T='';if(timer){clearInterval(timer);timer=null}document.getElementById('app').style.display='none';document.getElementById('nav').style.display='none';document.getElementById('login').style.display='flex'}
function render(s){
@@ -478,59 +451,53 @@ function render(s){
let t=`<div class="tiles">${tile(nf(s.total),'uploads saved')}${tile(nf(s.blocked),'blocked attempts',s.blocked?'amb':'')}${tile(nb,'banned IPs','','#bans')}${tile(pend.length,'awaiting approval',pend.length?'amb':'','#pending')}</div>`;
if(s.error)t+=`<div class="banner">${esc(s.error)}</div>`;
- t+=`<details id="pending" class="box sec${pend.length?' attn':''}" ${pend.length?'open':''}>
+ t+=`<details id="pending" class="box" ${pend.length?'open':''}>
<summary>Waiting for approval (${pend.length})</summary><div class="content">`;
if(pend.length){
- t+='<table class="cards"><tr><th>GitHub account</th><th>Repos</th><th>Syncs</th><th>Last sync</th><th></th></tr>';
- for(const [k,g] of pend)t+=`<tr>${cell('Account',`<a href="https://github.com/${encodeURIComponent(g.owner)}" target="_blank" rel="noopener">${esc(g.owner)}</a>`,'name')}${cell('Repos',esc((g.repos||[]).join(', ')))}${cell('Syncs',g.syncs)}${cell('Last sync',fmt(g.last))}<td class="act">${btn('Allow','green',GH,{key:k,action:'allow'})}${btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?')}</td></tr>`;
- t+='</table>';
+ t+='<div class="table-wrap"><table><tr><th>GitHub account</th><th>Repos</th><th>Syncs</th><th>Last sync</th><th></th></tr>';
+ for(const [k,g] of pend)t+=`<tr><td><a href="https://github.com/${encodeURIComponent(g.owner)}" target="_blank" rel="noopener" class="name">${esc(g.owner)}</a></td><td>${esc((g.repos||[]).join(', '))}</td><td>${g.syncs}</td><td>${fmt(g.last)}</td><td class="act">${btn('Allow','green',GH,{key:k,action:'allow'})} ${btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?')}</td></tr>`;
+ t+='</table></div>';
}else t+='<p class="dim" style="margin:2px 0">Nothing waiting. A new GitHub account shows up here after its first sync.</p>';
t+='</div></details>';
- t+=`<details id="bans" class="box sec"><summary>Banned IPs (${nb})</summary><div class="content"><table class="cards"><tr><th>IP</th><th>Since</th><th>Note</th><th></th></tr>`;
- for(const ip in banned)t+=`<tr>${cell('IP',esc(ip),'name mono')}${cell('Since',fmt(banned[ip].t))}${cell('Note',esc(banned[ip].note||''))}<td class="act">${btn('Unban','','/api/admin/unban',{ip})}</td></tr>`;
+ t+=`<details id="bans" class="box"><summary>Banned IPs (${nb})</summary><div class="content"><div class="table-wrap"><table><tr><th>IP</th><th>Since</th><th>Note</th><th></th></tr>`;
+ for(const ip in banned)t+=`<tr><td class="name mono">${esc(ip)}</td><td>${fmt(banned[ip].t)}</td><td>${esc(banned[ip].note||'')}</td><td class="act">${btn('Unban','','/api/admin/unban',{ip})}</td></tr>`;
if(!nb)t+=empty(4,'None.');
- t+='</table></div></details>';
+ t+='</table></div></div></details>';
document.getElementById('top').innerHTML=t;
- // Render valid HTML tables that transform into cards on mobile
- let u='<table class="cards log-cards"><tr><th>File</th><th>Status</th><th>Meta</th><th class="act"></th></tr>';
+ // Single Line Stagit-Style Table for Uploads
+ let u='<div class="table-wrap"><table><tr><th>File</th><th>Time</th><th>IP</th><th>Size</th><th>Via</th><th>Status</th><th></th></tr>';
for(const r of s.log){
const stBadge=r.status==='ok'?'<span class="badge ok">OK</span>':r.status==='deleted'?'<span class="badge dim">DEL</span>':`<span class="badge bad">${esc(r.status)}</span>`;
const live=r.path&&r.status==='ok';
- const acts=(r.method==='github'?'':r.ip in banned?'<span class="dim" style="font-size:11px">Banned</span>':btn('Ban IP','red','/api/admin/ban',{ip:r.ip},'Ban '+r.ip+'?'))+(live?btn('Delete','red','/api/admin/delete',{path:r.path},'Delete '+r.path+' from the CDN?'):'');
+ const acts=(r.method==='github'?'':r.ip in banned?'<span class="dim" style="font-size:11px">Banned</span>':btn('Ban IP','red','/api/admin/ban',{ip:r.ip},'Ban '+r.ip+'?'))+(live?' '+btn('Delete','red','/api/admin/delete',{path:r.path},'Delete '+r.path+' from the CDN?'):'');
const nameLink=live?`<a href="/${encodeURI(r.path)}" target="_blank" rel="noopener">${esc(r.name)}</a>`:esc(r.name);
u+=`<tr>
- <td class="td-name">${nameLink}</td>
- <td class="td-status">${stBadge}</td>
- <td class="td-meta">
- <span>${fmt(r.t)}</span>
- <span class="dim">•</span>
- <span class="mono">${esc(r.ip)}</span>
- <span class="dim">•</span>
- <span>${sz(r.size)}</span>
- <span class="dim">•</span>
- <span>${esc(r.method)}</span>
- ${r.extra?`<span class="dim">• ${esc(r.extra)}</span>`:''}
- </td>
- <td class="td-act act">${acts}</td>
+ <td class="name">${nameLink}${r.extra?` <span class="dim" style="font-size:11px">(${esc(r.extra)})</span>`:''}</td>
+ <td class="nw">${fmt(r.t)}</td>
+ <td class="mono">${esc(r.ip)}</td>
+ <td class="nw">${sz(r.size)}</td>
+ <td>${esc(r.method)}</td>
+ <td>${stBadge}</td>
+ <td class="act">${acts}</td>
</tr>`;
}
- if(!s.log.length)u+=empty(4,Q?'Nothing matches your search.':'No uploads recorded yet.');
- document.getElementById('uploads').innerHTML=u+'</table>';
+ if(!s.log.length)u+=empty(7,Q?'Nothing matches your search.':'No uploads recorded yet.');
+ document.getElementById('uploads').innerHTML=u+'</table></div>';
document.getElementById('qinfo').textContent=Q?`${s.matched} match${s.matched===1?'':'es'} of ${s.total}`:`${nf(s.total)} saved`;
- let r=`<details id="gh-sec" class="box sec"><summary>GitHub accounts (${s.gh.length})</summary><div class="content"><table class="cards"><tr><th>Account</th><th>Status</th><th>Repos</th><th>Syncs</th><th>Last IP</th><th>Last sync</th><th></th></tr>`;
+ let r=`<details id="gh-sec" class="box"><summary>GitHub accounts (${s.gh.length})</summary><div class="content"><div class="table-wrap"><table><tr><th>Account</th><th>Status</th><th>Repos</th><th>Syncs</th><th>Last IP</th><th>Last sync</th><th></th></tr>`;
for(const [k,g] of s.gh){
- const c=g.status==='allowed'?'ok':g.status==='banned'?'bad':'warn';
- r+=`<tr>${cell('Account',esc(g.owner),'name')}${cell('Status',esc(g.status),c)}${cell('Repos',esc((g.repos||[]).join(', ')))}${cell('Syncs',g.syncs)}${cell('Last IP',esc(g.last_ip),'mono')}${cell('Last sync',fmt(g.last))}<td class="act">${g.status!=='allowed'?btn('Allow','green',GH,{key:k,action:'allow'}):btn('Revoke','',GH,{key:k,action:'revoke'})}${g.status!=='banned'?btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?'):''}</td></tr>`;
+ const c=g.status==='allowed'?'<span class="badge ok">OK</span>':g.status==='banned'?'<span class="badge bad">BANNED</span>':'<span class="badge warn">PENDING</span>';
+ r+=`<tr><td class="name">${esc(g.owner)}</td><td>${c}</td><td>${esc((g.repos||[]).join(', '))}</td><td>${g.syncs}</td><td class="mono">${esc(g.last_ip)}</td><td>${fmt(g.last)}</td><td class="act">${g.status!=='allowed'?btn('Allow','green',GH,{key:k,action:'allow'}):btn('Revoke','',GH,{key:k,action:'revoke'})} ${g.status!=='banned'?btn('Ban','red',GH,{key:k,action:'ban'},'Ban this GitHub account?'):''}</td></tr>`;
}
if(!s.gh.length)r+=empty(7,'No GitHub syncs yet.');
- r+=`</table><p class="dim" style="font-size:12px;margin:8px 0 0">Always allowed: ${esc(s.always_allow.join(', ')||'none')}</p></div></details>`;
+ r+=`</table></div><p class="dim" style="font-size:12px;margin:8px 0 0">Always allowed: ${esc(s.always_allow.join(', ')||'none')}</p></div></details>`;
const w=s.whoami,kv=(a,b)=>`<tr><td>${a}</td><td class="mono">${esc(b==null||b===''?'-':b)}</td></tr>`;
- r+=`<details id="who" class="box sec who"${!w.ok?' open':''}><summary>Your IP: <span class="mono ${w.ok?'ok':'bad'}">${esc(w.detected)}</span> ${w.ok?'✓':'✗ looks wrong'}</summary><div class="content"><table class="kv">${kv('X-Forwarded-For',w.x_forwarded_for)}${kv('CF-Connecting-IP',w.cf_connecting_ip)}${kv('Socket peer',w.socket_peer)}${kv('Settings','TRUST_CF_CONNECTING_IP='+(w.trust_cf_header?1:0)+', TRUSTED_PROXY_HOPS='+w.proxy_hops)}</table><p class="dim">Must be your real public IP, or bans will hit the wrong address.</p></div></details>`;
+ r+=`<details id="who" class="box who"${!w.ok?' open':''}><summary>Your IP: <span class="mono ${w.ok?'ok':'bad'}">${esc(w.detected)}</span> ${w.ok?'✓':'✗ looks wrong'}</summary><div class="content"><div class="table-wrap"><table>${kv('X-Forwarded-For',w.x_forwarded_for)}${kv('CF-Connecting-IP',w.cf_connecting_ip)}${kv('Socket peer',w.socket_peer)}${kv('Settings','TRUST_CF_CONNECTING_IP='+(w.trust_cf_header?1:0)+', TRUSTED_PROXY_HOPS='+w.proxy_hops)}</table></div><p class="dim">Must be your real public IP, or bans will hit the wrong address.</p></div></details>`;
document.getElementById('rest').innerHTML=r;
}
document.getElementById('q').addEventListener('input',e=>{clearTimeout(qt);qt=setTimeout(()=>{Q=e.target.value.trim();load()},250)});