cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
commit 85c0941eab48dd9389ca0e4e4f68f9e43e0630bb
parent a7a34d8fbf7199736f3eb4d55a851c9385ff3ff1
Author: Amit Dutta <mail@amit.is-a.dev>
Date:   Sat, 10 Oct 2026 05:58:33 +0530

Merge pull request #35 from notamitgamer/nav-admin-button

Navbar: add an Admin button, redesinged the admin page
Diffstat:
Mapp/admin.py | 165++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mapp/templates/_navbar.html | 5+++++
2 files changed, 106 insertions(+), 64 deletions(-)

diff --git a/app/admin.py b/app/admin.py @@ -329,59 +329,75 @@ 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> :root{--bg:#0d1117;--sf:#161b22;--bd:#30363d;--ln:#21262d;--tx:#e6edf3;--dim:#9da7b3;--red:#ff7b72;--grn:#56d364;--amb:#d29922;--blu:#58a6ff} -*{box-sizing:border-box} -body{font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;margin:0 auto;padding:14px 14px 64px;background:var(--bg);color:var(--tx);max-width:1200px;-webkit-text-size-adjust:100%} +*{box-sizing:border-box;-webkit-tap-highlight-color:transparent} +body{font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;margin:0 auto;padding:14px 14px 64px;background:var(--bg);color:var(--tx);max-width:1200px;-webkit-text-size-adjust:100%} header.bar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px} -h1{font-size:19px;margin:0}h2{font-size:15px;margin:24px 0 8px;color:var(--dim);font-weight:600;scroll-margin-top:12px} +h1{font-size:18px;margin:0}h2{font-size:14px;margin:20px 0 8px;color:var(--dim);font-weight:600;scroll-margin-top:54px} a{color:var(--blu);text-decoration:none} .links{display:flex;gap:8px;align-items:center} -.tiles{display:grid;grid-template-columns:repeat(2,1fr);gap:8px} -.tile{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:10px;padding:10px 12px;color:var(--tx)} -.tile b{display:block;font-size:21px;line-height:1.2}.tile span{font-size:12px;color:var(--dim)}.tile.amb{border-color:var(--amb)}.tile.amb b{color:var(--amb)} +.tiles{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px} +.tile{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:8px;padding:8px 12px;color:var(--tx);text-decoration:none} +.tile b{display:block;font-size:20px;line-height:1.2}.tile span{font-size:11px;color:var(--dim)}.tile.amb{border-color:var(--amb)}.tile.amb b{color:var(--amb)} + +/* Sticky Mobile Quick Nav */ +.jump-bar{position:sticky;top:0;z-index:20;background:var(--bg);padding:6px 0 8px;display:flex;gap:6px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;border-bottom:1px solid var(--ln);margin-bottom:8px} +.jump-bar::-webkit-scrollbar{display: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 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;border-bottom:1px solid var(--ln);vertical-align:top}th{color:var(--dim);font-weight:600} -.act{white-space:nowrap}.name{overflow-wrap:anywhere} -button{font:inherit;font-size:13px;min-height:34px;padding:5px 12px;border-radius:8px;border:1px solid var(--bd);background:var(--sf);color:var(--tx);cursor:pointer} +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(--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:16px;padding:10px 12px;border-radius:8px;border:1px solid var(--bd);background:var(--bg);color:var(--tx);width:100%;max-width:420px} +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} .dim{color:var(--dim)}.bad{color:var(--red)}.ok{color:var(--grn)}.warn{color:var(--amb)} -.banner{background:#3d1d1d;border:1px solid #da3633;padding:9px 12px;border-radius:8px;margin-top:10px} -.sec{border-radius:10px;scroll-margin-top:10px}.sec.attn{border-left:3px solid var(--amb);padding-left:10px} -.flash{animation:flash 1.8s ease-out}@keyframes flash{0%{box-shadow:0 0 0 4px var(--amb);background:#2a2110}100%{box-shadow:0 0 0 4px transparent;background:transparent}} -@media(prefers-reduced-motion:reduce){.flash{animation:none}} -.who{margin-top:22px;background:var(--sf);border:1px solid var(--bd);border-radius:10px;padding:10px 12px}.who summary{cursor:pointer} -.who table{margin-top:8px}.who p{margin:8px 0 0;font-size:12px} +.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} + +/* Collapsible sections */ +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: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} -#qrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}#qrow input{flex:1 1 260px} -@media(min-width:721px){.tiles{grid-template-columns:repeat(4,1fr)}th,td{padding:8px 10px}} +#qrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}#qrow input{flex:1 1 240px} +#qinfo{font-size:12px} + +@media(min-width:721px){ + .tiles{grid-template-columns:repeat(4,1fr)} + .jump-bar{display:none} + details.box summary{display:none} + details.box .content{margin-top:0} +} @media(max-width:720px){ - body{padding:12px 12px 64px} - table.cards,table.cards tbody{display:block} - table.cards tr{display:block;background:var(--sf);border:1px solid var(--bd);border-radius:10px;padding:10px 12px;margin:0 0 10px} - table.cards tr:first-child{display:none} - table.cards td{display:flex;gap:14px;justify-content:space-between;align-items:baseline;border:0;padding:3px 0;text-align:right;overflow-wrap:anywhere} - table.cards td::before{content:attr(data-l);color:var(--dim);flex:none;text-align:left} - table.cards td.name{display:block;text-align:left;font-size:14px;font-weight:600;padding-bottom:6px}table.cards td.name::before,table.cards td.act::before{display:none} - table.cards td:empty{display:none} - table.cards td.empty{display:block;text-align:left;color:var(--dim)} - table.cards td.act{display:flex;justify-content:stretch;gap:8px;margin-top:8px;padding-top:10px;border-top:1px solid var(--ln)} - table.cards td.act button{flex:1;min-height:44px;font-size:14px} - table.kv tr{display:block;padding:6px 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:12px} + body{padding:10px 10px 64px} + /* 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> <div id="login"><input id="tok" type="password" placeholder="Admin token" autocomplete="current-password"> <button onclick="go()">Open</button> <span id="lerr" class="bad"></span></div> <div id="app" style="display:none"> +<div class="jump-bar" id="jumpbar"></div> <div id="top"></div> -<h2 id="uploads-h">Recent uploads</h2> -<div id="qrow"><input id="q" type="search" placeholder="Search file name, link, IP, GitHub repo, status..." autocomplete="off"><span id="qinfo" class="dim"></span></div> +<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> </div> @@ -398,17 +414,13 @@ async function api(p,body){ } function go(){T=document.getElementById('tok').value.trim();sessionStorage.setItem('t',T);load(true)} const btn=(label,cls,path,body,ask)=>`<button class="${cls}" data-p="${esc(path)}" data-b="${esc(JSON.stringify(body))}" data-ask="${esc(ask||'')}">${esc(label)}</button>`; -document.addEventListener('click',e=>{ - const a=e.target.closest('a.tile[href^="#"]');if(!a)return; - const el=document.querySelector(a.getAttribute('href'));if(!el)return; - e.preventDefault();el.scrollIntoView({behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'}); - el.classList.remove('flash');void el.offsetWidth;el.classList.add('flash'); -}); + document.addEventListener('click',async e=>{ const b=e.target.closest('button[data-p]');if(!b)return; if(b.dataset.ask&&!confirm(b.dataset.ask))return; try{await api(b.dataset.p,JSON.parse(b.dataset.b));load()}catch(err){alert(err.message)} }); + async function load(first){ try{ const s=await api('/api/admin/state?q='+encodeURIComponent(Q)); @@ -421,47 +433,72 @@ async function load(first){ } } const GH='/api/admin/gh'; -let WHO=false; -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){ const banned=s.bans,nb=Object.keys(banned).length,pend=s.gh.filter(([k,g])=>g.status==='pending'&&g.syncs>=1); + + document.getElementById('jumpbar').innerHTML=` + <a href="#uploads-h">Uploads (${nf(s.matched)})</a> + <a href="#pending"${pend.length?' class="has-badge"':''}>Approval (${pend.length})</a> + <a href="#bans">Bans (${nb})</a> + <a href="#gh-sec">Accounts</a> + <a href="#who">Server IP</a> + `; + const tile=(n,l,c,href)=>`<${href?`a href="${href}"`:'div'} class="tile ${c||''}"><b>${n}</b><span>${l}</span></${href?'a':'div'}>`; 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+=`<section id="pending" class="sec${pend.length?' attn':''}"><h2>Waiting for approval</h2>`; + + 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>'; - }else t+='<p class="dim" style="margin:0">Nothing waiting. A new GitHub account shows up here after its first sync.</p>'; - t+='</section>'; - t+='<section id="bans" class="sec"><h2>Banned IPs</h2><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+='<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"><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></section>'; + t+='</table></div></div></details>'; document.getElementById('top').innerHTML=t; - let u='<table class="cards"><tr><th>File</th><th>Time</th><th>IP</th><th>Size</th><th>Via</th><th>Status</th><th></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 st=r.status==='ok'?'ok':r.status==='deleted'?'dim':'bad',live=r.path&&r.status==='ok'; - const acts=(r.method==='github'?'':r.ip in banned?'<span class="dim">IP 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?'):''); - u+=`<tr>${cell('File',(live?`<a href="/${encodeURI(r.path)}" target="_blank" rel="noopener">${esc(r.name)}</a>`:esc(r.name))+(r.extra?`<div class="dim" style="font-weight:400">${esc(r.extra)}</div>`:''),'name')}${cell('Time',fmt(r.t),'nw')}${cell('IP',esc(r.ip),'mono')}${cell('Size',sz(r.size),'nw')}${cell('Via',esc(r.method))}${cell('Status',esc(r.status),st)}${acts?`<td class="act">${acts}</td>`:'<td class="act" style="display:none"></td>'}</tr>`; + 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 nameLink=live?`<a href="/${encodeURI(r.path)}" target="_blank" rel="noopener">${esc(r.name)}</a>`:esc(r.name); + + u+=`<tr> + <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(7,Q?'Nothing matches your search.':'No uploads recorded yet.'); - document.getElementById('uploads').innerHTML=u+'</table>'; - document.getElementById('qinfo').textContent=Q?`${s.matched} match${s.matched===1?'':'es'} of ${s.total}${s.matched>s.log.length?' (showing newest '+s.log.length+')':''}`:`${nf(s.total)} saved`; + 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='<h2>GitHub accounts</h2><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>'; - 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>`} + 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'?'<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">Always allowed: ${esc(s.always_allow.join(', ')||'none')}</p>`; + 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="who"${WHO||!w.ok?' open':''}><summary>Your IP as the server sees it: <span class="mono ${w.ok?'ok':'bad'}">${esc(w.detected)}</span> ${w.ok?'&#10003;':'&#10007; looks wrong'}</summary><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">This must be your real public IP, or bans will hit the wrong address.</p></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?'&#10003;':'&#10007; 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('who').addEventListener('toggle',e=>{WHO=e.target.open}); } document.getElementById('q').addEventListener('input',e=>{clearTimeout(qt);qt=setTimeout(()=>{Q=e.target.value.trim();load()},250)}); document.getElementById('tok').addEventListener('keydown',e=>{if(e.key==='Enter')go()}); diff --git a/app/templates/_navbar.html b/app/templates/_navbar.html @@ -21,6 +21,8 @@ } .navlinks a svg { flex-shrink: 0; } .navlinks a:hover { color: var(--ui-text); background: var(--ui-surface); } +.navlinks a.nav-admin { padding: 0.4rem 0.5rem; margin-left: 0.25rem; border: 1px solid var(--ui-border); } +.navlinks a.nav-admin span { display: none; } .navlinks a.active { color: var(--ui-accent); background: var(--ui-accent-bg); font-weight: 500; } .nav-toggle { display: none; align-items: center; justify-content: center; @@ -38,6 +40,8 @@ .navlinks { display: none; flex-basis: 100%; flex-direction: column; align-items: stretch; gap: 0.15rem; padding-top: 0.5rem; } .navlinks.open { display: flex; } .navlinks a { padding: 0.65rem 0.75rem; font-size: 1rem; } + .navlinks a.nav-admin { padding: 0.65rem 0.75rem; margin-left: 0; border: 0; } + .navlinks a.nav-admin span { display: inline; } } .breadcrumb-container { max-width: var(--ui-width); margin: 0 auto; padding: 0 1rem 0.75rem; } .breadcrumb { @@ -76,6 +80,7 @@ <a href="/stats" class="{{ 'active' if page == 'stats' }}"{{ ' aria-current="page"'|safe if page == 'stats' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="20" x2="6" y2="12"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="18" y1="20" x2="18" y2="9"/></svg><span>Stats</span></a> <a href="/gh-sync" class="{{ 'active' if page == 'gh-sync' }}"{{ ' aria-current="page"'|safe if page == 'gh-sync' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg><span>Sync</span></a> <a href="/documentation" class="{{ 'active' if page == 'documentation' }}"{{ ' aria-current="page"'|safe if page == 'documentation' }}><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg><span>Docs</span></a> + <a href="/admin" class="nav-admin" title="Admin" aria-label="Admin"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg><span>Admin</span></a> </nav> </header> {% if not (page == 'listing' and not path) %}