← back to Credentials Agentabrams
add 4-way view switcher (list/grid/table/kv) + search to credential dashboard
3bd0c68961ee2dd7be3e5414d3e8cf6a404f15d4 · 2026-09-23 09:53:44 -0700 · Steve Abrams
Files touched
Diff
commit 3bd0c68961ee2dd7be3e5414d3e8cf6a404f15d4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 09:53:44 2026 -0700
add 4-way view switcher (list/grid/table/kv) + search to credential dashboard
---
public/index.html | 165 +++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 137 insertions(+), 28 deletions(-)
diff --git a/public/index.html b/public/index.html
index 74c1ee5..732cd26 100644
--- a/public/index.html
+++ b/public/index.html
@@ -16,22 +16,32 @@
h1{margin:0 0 2px;font-size:20px;letter-spacing:.3px}
.sub{color:var(--dim);font-size:12.5px}
.sub code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--accent)}
- .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:var(--panel)}
+ .controls{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid var(--line);background:var(--panel);position:sticky;top:0;z-index:4}
.controls label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-right:6px}
select,input[type=range]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
input[type=range]{padding:0;width:150px}
+ input[type=search]{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 10px;font-size:13px;min-width:180px}
+ .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+ .seg button{background:var(--panel2);color:var(--dim);border:0;padding:6px 12px;font-size:12px;cursor:pointer;border-right:1px solid var(--line)}
+ .seg button:last-child{border-right:0}
+ .seg button.on{background:var(--accent);color:#fff;font-weight:650}
.stat{margin-left:auto;font-size:12.5px;color:var(--dim)}
.stat b{color:var(--ink)}
- main{padding:20px 24px;display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
- @media(max-width:900px){main{grid-template-columns:1fr}}
+ #densWrap{display:none}
+ main{padding:20px 24px}
+
+ /* ---- GRID (cards) ---- */
+ main.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px}
+ @media(max-width:900px){main.grid{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0}
.card.p1{border-left:3px solid var(--red)}
.card.resolved{opacity:.62}
.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.title{font-size:15px;font-weight:650;letter-spacing:.2px}
- .badge{font-size:10.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;padding:3px 8px;border-radius:20px}
+ .badge{font-size:10.5px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;padding:3px 8px;border-radius:20px;white-space:nowrap}
.b-out{background:rgba(229,83,75,.16);color:#ff8b83}
.b-res{background:rgba(63,185,80,.16);color:#6ee787}
+ .b-prog{background:rgba(224,164,75,.16);color:#f0b866}
.b-pri{background:rgba(224,164,75,.16);color:#f0b866}
.b-cls{background:rgba(68,147,248,.14);color:#79b8ff}
.when{font-size:11px;color:var(--dim);margin-left:auto}
@@ -40,8 +50,32 @@
.field .v{color:var(--ink)}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
- code{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:#c9d6e3;display:inline-block;max-width:100%;overflow-x:auto;white-space:nowrap}
+ code{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:#c9d6e3;display:inline-block;max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-word}
.why{font-size:12px;color:var(--dim);border-top:1px dashed var(--line);padding-top:8px}
+
+ /* ---- LIST (compact single column) ---- */
+ main.list{display:flex;flex-direction:column;gap:10px}
+ .li{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line);border-radius:10px;padding:12px 14px}
+ .li.p1{border-left-color:var(--red)}
+ .li .hd{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:4px}
+ .li .act{font-size:12.5px;color:var(--ink);margin-top:4px}
+ .li .act b{color:var(--teal)}
+
+ /* ---- TABLE ---- */
+ main.table{display:block;overflow-x:auto}
+ table{border-collapse:collapse;width:100%;font-size:12.5px}
+ th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
+ th{color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.5px;position:sticky;top:0;background:var(--panel)}
+ tr.p1 td:first-child{border-left:3px solid var(--red)}
+ td .action{color:var(--teal)}
+
+ /* ---- KV (flat key/value) ---- */
+ main.kv{display:flex;flex-direction:column;gap:20px}
+ .kvblock{border-bottom:1px solid var(--line);padding-bottom:16px}
+ .kvblock h3{margin:0 0 8px;font-size:14px}
+ .kvrow{display:grid;grid-template-columns:120px 1fr;gap:10px;padding:3px 0;font-size:12.5px}
+ .kvrow .kk{color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.5px;padding-top:2px}
+ @media(max-width:640px){.kvrow{grid-template-columns:1fr}}
</style>
</head>
<body>
@@ -50,6 +84,13 @@
<div class="sub">Every secret Steve must personally rotate/supply · umbrella <code>TK-11786</code> · source <code>ROTATION-CHECKLIST.md</code></div>
</header>
<div class="controls">
+ <div><label>View</label>
+ <span class="seg" id="viewSeg">
+ <button data-v="list">List</button>
+ <button data-v="grid">Grid</button>
+ <button data-v="table">Table</button>
+ <button data-v="kv">KV</button>
+ </span></div>
<div><label>Sort</label>
<select id="sort">
<option value="priority">Priority</option>
@@ -57,52 +98,120 @@
<option value="created">Newest</option>
<option value="title">Title A→Z</option>
</select></div>
- <div><label>Density</label><input type="range" id="dens" min="1" max="4" value="3"></div>
+ <div><input type="search" id="q" placeholder="Search credentials…"></div>
+ <div id="densWrap"><label>Density</label><input type="range" id="dens" min="1" max="4" value="3"></div>
<div class="stat" id="stat"></div>
</div>
-<main id="grid"></main>
+<main id="grid" class="list"></main>
<script>
const $ = s => document.querySelector(s);
-let ITEMS = [];
+let ITEMS = [], VIEW = 'list';
const PRI = { P1:0, P2:1, P3:2 };
const fmtDate = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch(e){ return iso; } };
+const esc = s => String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const statusBadge = s => s==='RESOLVED'?'b-res':(s==='IN PROGRESS'?'b-prog':'b-out');
+const actionOf = i => i.status==='RESOLVED' ? 'done ✓' : (i.console_steps ? 'console action' : 'route key');
+const linkify = t => { t=esc(t); return t.replace(/(https?:\/\/[^\s)]+)/g,'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'); };
-function render(){
+function sorted(){
const sort = $('#sort').value;
- const items = [...ITEMS].sort((a,b)=>{
+ const q = ($('#q').value||'').toLowerCase().trim();
+ let items = [...ITEMS];
+ if(q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q));
+ return items.sort((a,b)=>{
if(sort==='priority') return (PRI[a.priority]??9)-(PRI[b.priority]??9) || a.status.localeCompare(b.status);
if(sort==='status') return (a.status==='OUTSTANDING'?0:1)-(b.status==='OUTSTANDING'?0:1) || (PRI[a.priority]??9)-(PRI[b.priority]??9);
if(sort==='created') return new Date(b.created_at)-new Date(a.created_at);
return a.title.localeCompare(b.title);
});
- const out = items.filter(i=>i.status==='OUTSTANDING').length;
- $('#stat').innerHTML = `<b>${out}</b> outstanding · <b>${items.length}</b> total`;
- $('#grid').innerHTML = items.map(i=>{
+}
+
+const fieldsGrid = i => [
+ ['Exposed', esc(i.exposed||'—')],
+ i.mint_url ? ['Mint', `<a href="${esc(i.mint_url)}" target="_blank" rel="noopener noreferrer">${esc(i.mint_url)}</a>`] : null,
+ i.runbook ? ['Runbook', `<code>${esc(i.runbook)}</code>`] : null,
+ ['Console steps', linkify(i.console_steps||'—')],
+ ['Route back', `<code>${esc(i.route_back||'—')}</code>`],
+ ['Verify', `<code>${esc(i.verify||'—')}</code>`],
+].filter(Boolean);
+
+function renderGrid(items){
+ return items.map(i=>{
const resolved = i.status==='RESOLVED';
return `<div class="card ${i.priority==='P1'?'p1':''} ${resolved?'resolved':''}">
+ <div class="row"><span class="title">${esc(i.title)}</span><span class="when" title="${esc(i.created_at)}">🕓 ${fmtDate(i.created_at)}</span></div>
<div class="row">
- <span class="title">${i.title}</span>
- <span class="when" title="${i.created_at}">🕓 ${fmtDate(i.created_at)}</span>
- </div>
- <div class="row">
- <span class="badge ${resolved?'b-res':'b-out'}">${i.status}</span>
- <span class="badge b-pri">${i.priority}</span>
- <span class="badge b-cls">${i.class}</span>
+ <span class="badge ${statusBadge(i.status)}">${esc(i.status)}</span>
+ <span class="badge b-pri">${esc(i.priority)}</span>
+ <span class="badge b-cls">${esc(i.class)}</span>
</div>
- <div class="field"><span class="k">Exposed</span><span class="v">${i.exposed||'—'}</span></div>
- ${i.mint_url?`<div class="field"><span class="k">Mint</span><span class="v"><a href="${i.mint_url}" target="_blank" rel="noopener noreferrer">${i.mint_url}</a></span></div>`:''}
- ${i.runbook?`<div class="field"><span class="k">Runbook</span><span class="v"><code>${i.runbook}</code></span></div>`:''}
- <div class="field"><span class="k">Console steps</span><span class="v">${i.console_steps||'—'}</span></div>
- <div class="field"><span class="k">Route back</span><span class="v"><code>${i.route_back||'—'}</code></span></div>
- <div class="field"><span class="k">Verify</span><span class="v"><code>${i.verify||'—'}</code></span></div>
- <div class="why">${i.why||''}</div>
+ ${fieldsGrid(i).map(([k,v])=>`<div class="field"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('')}
+ ${i.why?`<div class="why">${esc(i.why)}</div>`:''}
</div>`;
}).join('');
}
+function renderList(items){
+ return items.map(i=>`<div class="li ${i.priority==='P1'?'p1':''}">
+ <div class="hd">
+ <span class="title">${esc(i.title)}</span>
+ <span class="badge ${statusBadge(i.status)}">${esc(i.status)}</span>
+ <span class="badge b-pri">${esc(i.priority)}</span>
+ <span class="when" title="${esc(i.created_at)}">🕓 ${fmtDate(i.created_at)}</span>
+ </div>
+ <div class="act"><b>${actionOf(i)}:</b> ${linkify(i.console_steps||i.route_back||'—')}</div>
+ </div>`).join('');
+}
+
+function renderTable(items){
+ const rows = items.map(i=>`<tr class="${i.priority==='P1'?'p1':''}">
+ <td>${esc(i.title)}</td>
+ <td><span class="badge ${statusBadge(i.status)}">${esc(i.status)}</span></td>
+ <td>${esc(i.priority)}</td>
+ <td>${esc(i.class)}</td>
+ <td class="action">${esc(actionOf(i))}</td>
+ <td>${linkify(i.console_steps||i.route_back||'—')}</td>
+ </tr>`).join('');
+ return `<table><thead><tr><th>Credential</th><th>Status</th><th>Pri</th><th>Class</th><th>Action</th><th>Next step</th></tr></thead><tbody>${rows}</tbody></table>`;
+}
+
+function renderKv(items){
+ return items.map(i=>{
+ const rows = [
+ ['status', esc(i.status)], ['priority', esc(i.priority)], ['class', esc(i.class)],
+ ['created', fmtDate(i.created_at)],
+ ['exposed', esc(i.exposed||'—')],
+ i.mint_url?['mint', `<a href="${esc(i.mint_url)}" target="_blank" rel="noopener noreferrer">${esc(i.mint_url)}</a>`]:null,
+ ['console', linkify(i.console_steps||'—')],
+ i.runbook?['runbook', `<code>${esc(i.runbook)}</code>`]:null,
+ ['route', `<code>${esc(i.route_back||'—')}</code>`],
+ ['verify', `<code>${esc(i.verify||'—')}</code>`],
+ i.why?['why', esc(i.why)]:null,
+ ].filter(Boolean);
+ return `<div class="kvblock"><h3>${esc(i.title)}</h3>${rows.map(([k,v])=>`<div class="kvrow"><span class="kk">${k}</span><span class="vv">${v}</span></div>`).join('')}</div>`;
+ }).join('');
+}
+
+function render(){
+ const items = sorted();
+ const out = items.filter(i=>i.status!=='RESOLVED').length;
+ $('#stat').innerHTML = `<b>${out}</b> open · <b>${items.length}</b> shown`;
+ const main = $('#grid');
+ main.className = VIEW;
+ $('#densWrap').style.display = VIEW==='grid' ? '' : 'none';
+ main.innerHTML = VIEW==='grid'?renderGrid(items):VIEW==='table'?renderTable(items):VIEW==='kv'?renderKv(items):renderList(items);
+ document.querySelectorAll('#viewSeg button').forEach(b=>b.classList.toggle('on', b.dataset.v===VIEW));
+}
+
+document.querySelectorAll('#viewSeg button').forEach(b=>b.onclick=()=>{ VIEW=b.dataset.v; try{localStorage.setItem('cred_view',VIEW);}catch(e){} render(); });
$('#sort').onchange = ()=>{ render(); try{localStorage.setItem('cred_sort',$('#sort').value);}catch(e){} };
+$('#q').oninput = render;
$('#dens').oninput = e=>{ document.documentElement.style.setProperty('--cols', e.target.value); try{localStorage.setItem('cred_dens',e.target.value);}catch(e){} };
-try{ const s=localStorage.getItem('cred_sort'); if(s)$('#sort').value=s; const d=localStorage.getItem('cred_dens'); if(d){$('#dens').value=d;document.documentElement.style.setProperty('--cols',d);} }catch(e){}
+try{
+ const v=localStorage.getItem('cred_view'); if(v)VIEW=v;
+ const s=localStorage.getItem('cred_sort'); if(s)$('#sort').value=s;
+ const d=localStorage.getItem('cred_dens'); if(d){$('#dens').value=d;document.documentElement.style.setProperty('--cols',d);}
+}catch(e){}
fetch('/api/credentials').then(r=>r.json()).then(d=>{ ITEMS=d.items||[]; render(); }).catch(e=>{ $('#grid').innerHTML='<p style="color:var(--red)">Failed to load: '+e+'</p>'; });
</script>
← 89e7b0f update shopify credential status — 6755 live + routed, revok
·
back to Credentials Agentabrams
·
group credential dashboard: action-needed on top, green conn fca2e63 →