← back to Projects Agentabrams

public/index.html

222 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>projects.agentabrams.com — Project Viewer</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%2328412f'/%3E%3Ctext x='8' y='12' text-anchor='middle' font-family='Georgia,serif' font-size='11' fill='%23f7f1e6'%3Ep%3C/text%3E%3C/svg%3E">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
  :root { --rail: 320px; --bg:#0e1116; --panel:#161b22; --line:#2b313b; --txt:#dfe6ee; --dim:#8b95a3; --accent:#3fb6a8; }
  * { box-sizing: border-box; }
  body { margin:0; height:100vh; display:flex; font:14px/1.45 -apple-system, "SF Pro Text", Helvetica, sans-serif; background:var(--bg); color:var(--txt); }

  #rail { width:var(--rail); min-width:250px; max-width:520px; display:flex; flex-direction:column; border-right:1px solid var(--line); background:var(--panel); }
  #rail header { padding:14px 14px 10px; border-bottom:1px solid var(--line); }
  #rail h1 { margin:0 0 8px; font-size:15px; font-weight:700; letter-spacing:.02em; }
  #rail h1 small { color:var(--dim); font-weight:400; }
  #q { width:100%; padding:7px 10px; border-radius:7px; border:1px solid var(--line); background:var(--bg); color:var(--txt); outline:none; }
  #q:focus { border-color:var(--accent); }
  #filters { display:flex; gap:6px; margin-top:8px; }
  #filters button { flex:1; padding:5px 6px; font-size:11.5px; }
  #filters button.on { border-color:var(--accent); color:var(--accent); background:#16221f; }
  #list { flex:1; overflow-y:auto; padding:6px; }
  .sec { margin-bottom:1px; }
  .sec-h { display:flex; align-items:center; gap:7px; padding:8px 9px; border-radius:7px; cursor:pointer; user-select:none;
           color:var(--dim); font-size:11px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; }
  .sec-h:hover { background:#1e2530; color:var(--txt); }
  .sec-h .chev { font-size:9px; transition:transform .12s ease; flex:none; color:var(--dim); }
  .sec-h.open .chev { transform:rotate(90deg); color:var(--accent); }
  .sec-h .sec-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .sec-h .cnt { flex:none; color:var(--dim); font-weight:600; display:flex; align-items:center; gap:4px; }
  .sec-h .up-dot { width:6px; height:6px; border-radius:50%; background:#3fbf6f; display:inline-block; }
  .sec-body { padding:2px 0 4px 6px; }
  .item { display:flex; align-items:center; gap:8px; padding:7px 9px; border-radius:7px; cursor:pointer; color:var(--txt); user-select:none; }
  .item:hover { background:#1e2530; }
  .item.active { background:#213132; outline:1px solid var(--accent); }
  .dot { width:8px; height:8px; border-radius:50%; flex:none; background:#555; }
  .dot.up { background:#3fbf6f; }
  .dot.auth { background:#d9a53a; }
  .dot.login { background:#4a90e2; }
  .dot.down { background:#d95555; }
  .name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .name b { color:var(--accent); font-weight:600; }
  .badge { font-size:10px; padding:1px 6px; border-radius:10px; flex:none; border:1px solid var(--line); color:var(--dim); }
  .badge.public { color:#7fd6c9; border-color:#2e5049; }
  .ms { color:var(--dim); font-size:11px; flex:none; min-width:30px; text-align:right; }
  #foot { padding:8px 14px; border-top:1px solid var(--line); color:var(--dim); font-size:11.5px; }

  #main { flex:1; display:flex; flex-direction:column; min-width:0; }
  #bar { display:flex; align-items:center; gap:10px; padding:8px 12px; border-bottom:1px solid var(--line); background:var(--panel); }
  #cur { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  #cur a { color:var(--accent); text-decoration:none; }
  #cur .sub { color:var(--dim); font-weight:400; font-size:12px; }
  #bar .sp { flex:1; }
  button { padding:6px 12px; border-radius:7px; border:1px solid var(--line); background:var(--bg); color:var(--txt); cursor:pointer; font-size:13px; }
  button:hover { border-color:var(--accent); color:var(--accent); }
  #frame { flex:1; border:0; width:100%; background:#fff; }
  #empty { flex:1; display:flex; align-items:center; justify-content:center; color:var(--dim); flex-direction:column; gap:8px; }
  #empty .big { font-size:40px; }
</style>
</head>
<body>
  <div id="rail">
    <header>
      <h1>projects.agentabrams.com <small id="count"></small></h1>
      <input id="q" type="search" placeholder="Filter projects…" autocomplete="off">
      <div id="filters">
        <button data-f="all" class="on">All</button>
        <button data-f="public">Public</button>
        <button data-f="local">Local</button>
      </div>
    </header>
    <div id="list"></div>
    <div id="foot">click a project → loads live in the right panel · <span class="badge public">public</span> = live domain, <span class="badge">local</span> = pm2 port · dot: <span style="color:#3fbf6f">up</span> / <span style="color:#d9a53a">basic</span> / <span style="color:#4a90e2">login</span> / <span style="color:#d95555">down</span></div>
  </div>
  <div id="main">
    <div id="bar">
      <span id="cur">— pick a project</span>
      <span class="sp"></span>
      <button id="reload" title="Reload iframe">⟳ Reload</button>
      <button id="newtab" title="Open the real site in a new browser tab">↗ New Tab</button>
    </div>
    <div id="empty"><div class="big">🗂️</div><div>Select a project on the left to view its live site here.</div></div>
    <iframe id="frame" style="display:none"></iframe>
  </div>

<script>
let projects = [], status = {}, filter = 'all';
let current = localStorage.getItem('paa-current') || '';
const list = document.getElementById('list'), q = document.getElementById('q');
const frame = document.getElementById('frame'), empty = document.getElementById('empty');
const cur = document.getElementById('cur'), count = document.getElementById('count');

const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));

// Section order for the collapsible rail. Any category not listed sorts last.
const CATS = ['DW Vendor Lines','DW Catalog & Marketing',"Wallpaper's Back & Patterns",
  'Consulting & Client Sites','Directories & Verticals','Webdev Accelerator','Agents, Ops & Tools','Other'];
// collapsed[cat] === false → user opened it. Undefined → collapsed by default (compact).
let collapsed = {};
try { collapsed = JSON.parse(localStorage.getItem('paa-collapsed') || '{}'); } catch (e) {}

function buildRow(p, f) {
  const s = status[p.slug];
  const row = document.createElement('div');
  row.className = 'item' + (p.slug === current ? ' active' : '');
  const dotCls = !s ? '' : s.authFail ? 'down'
    : s.authType === 'basic' ? 'auth'
    : s.authType === 'login' ? 'login'
    : s.authType === 'down'  ? 'down'
    : s.up ? 'up' : 'down';
  const tip = !s ? 'probing…' : s.authType === 'down' ? 'unreachable'
    : s.authFail ? 'basic-auth wall — unified pw rejected'
    : s.authType === 'basic' ? 'HTTP Basic wall (auto-credential applied)'
    : s.authType === 'login' ? 'app login / SSO'
    : 'up — no auth';
  const en = esc(p.name);
  const label = f ? en.replace(new RegExp('(' + f.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')','ig'), '<b>$1</b>') : en;
  const badge = p.kind === 'public'
    ? `<span class="badge public" title="${esc(p.domain)}">public</span>`
    : `<span class="badge" title="127.0.0.1:${p.port}">:${p.port}</span>`;
  row.innerHTML = `<span class="dot ${dotCls}" title="${tip}"></span>`
    + `<span class="name">${label}</span>${badge}`
    + (s && s.up ? `<span class="ms">${s.ms}ms</span>` : s ? `<span class="ms">—</span>` : `<span class="ms"></span>`);
  row.onclick = () => select(p.slug);
  return row;
}

function render() {
  const f = q.value.trim().toLowerCase();
  const searching = f.length > 0;
  const shown = projects.filter(p =>
    (filter === 'all' || p.kind === filter) &&
    (p.name.toLowerCase().includes(f) || p.slug.toLowerCase().includes(f) || (p.domain || '').includes(f))
  );
  count.textContent = `(${shown.length}/${projects.length})`;

  // Group into ordered categories (unknown categories appended after CATS).
  const groups = new Map(CATS.map(c => [c, []]));
  for (const p of shown) {
    const c = p.category || 'Other';
    if (!groups.has(c)) groups.set(c, []);
    groups.get(c).push(p);
  }
  const curCat = current ? (projects.find(p => p.slug === current) || {}).category : null;

  list.innerHTML = '';
  for (const [cat, items] of groups) {
    if (!items.length) continue;
    // Open when: actively searching, this holds the current selection, or the
    // user explicitly opened it. Otherwise collapsed (the compact default).
    const open = searching || cat === curCat || collapsed[cat] === false;
    const sec = document.createElement('div');
    sec.className = 'sec';
    const head = document.createElement('div');
    head.className = 'sec-h' + (open ? ' open' : '');
    const upCount = items.filter(p => (status[p.slug] || {}).up).length;
    head.innerHTML = `<span class="chev">▸</span><span class="sec-name">${esc(cat)}</span>`
      + `<span class="cnt">${upCount ? `<i class="up-dot"></i>${upCount}/` : ''}${items.length}</span>`;
    head.onclick = () => {
      // Toggle across 3 states (undefined/true = closed, false = open):
      // undefined→false(open), false→true(closed), true→false(open).
      collapsed[cat] = (collapsed[cat] === false);
      localStorage.setItem('paa-collapsed', JSON.stringify(collapsed));
      render();
    };
    sec.appendChild(head);
    if (open) {
      const body = document.createElement('div');
      body.className = 'sec-body';
      for (const p of items) body.appendChild(buildRow(p, f));
      sec.appendChild(body);
    }
    list.appendChild(sec);
  }
}

function select(slug) {
  const p = projects.find(x => x.slug === slug);
  if (!p) return;
  current = slug;
  localStorage.setItem('paa-current', slug);
  cur.innerHTML = `<a href="${esc(p.target)}" target="_blank" rel="noopener noreferrer">${esc(p.name)}</a> `
    + `<span class="sub">${esc(p.domain || ('127.0.0.1:' + p.port))}</span>`;
  empty.style.display = 'none';
  frame.style.display = 'block';
  frame.src = '/site/' + encodeURIComponent(slug) + '/';
  render();
}

document.getElementById('reload').onclick = () => { if (current) frame.src = '/site/' + encodeURIComponent(current) + '/'; };
document.getElementById('newtab').onclick = () => {
  const p = projects.find(x => x.slug === current);
  if (p) window.open(p.target, '_blank', 'noopener');
};
q.oninput = render;
for (const b of document.querySelectorAll('#filters button')) {
  b.onclick = () => {
    filter = b.dataset.f;
    document.querySelectorAll('#filters button').forEach(x => x.classList.toggle('on', x === b));
    render();
  };
}

(async () => {
  try {
    const r = await fetch('/api/projects');
    if (!r.ok) throw new Error('projects HTTP ' + r.status);
    projects = await r.json();
    render();
    if (current && projects.some(p => p.slug === current)) select(current);
  } catch (e) { count.textContent = '(load failed — reload)'; return; }
  try {
    const sres = await fetch('/api/status');
    if (!sres.ok) throw new Error('status HTTP ' + sres.status);
    for (const s of await sres.json()) status[s.slug] = s;
    render();
  } catch (e) { /* dots stay grey; reload retries */ }
})();
</script>
</body>
</html>