[object Object]

← 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 →