← back to Slack Idea Board

public/index.html

241 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Idea Board · Slack feed</title>
<style>
  :root{--bg:#0f1117;--panel:#161922;--line:#262b38;--text:#e6e8ee;--dim:#9aa3b2;--gold:#c9a227;
    --yes:#2ecc71;--maybe:#e0b000;--no:#e05a5a;--chip:#1d2230;}
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
  header{position:sticky;top:0;z-index:5;background:linear-gradient(#0f1117,#0f1117ee);border-bottom:1px solid var(--line);
    padding:14px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
  h1{font-size:16px;margin:0;letter-spacing:.3px}
  h1 b{color:var(--gold)}
  .meta{color:var(--dim);font-size:12px}
  .spacer{flex:1}
  .live{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dim)}
  .dot{width:8px;height:8px;border-radius:50%;background:var(--yes);box-shadow:0 0 0 0 var(--yes);animation:p 2s infinite}
  @keyframes p{0%{box-shadow:0 0 0 0 rgba(46,204,113,.5)}70%{box-shadow:0 0 0 7px rgba(46,204,113,0)}100%{box-shadow:0 0 0 0 rgba(46,204,113,0)}}
  table{width:100%;border-collapse:collapse}
  thead th{position:sticky;top:57px;background:#12151d;text-align:left;font-size:11px;letter-spacing:.08em;
    text-transform:uppercase;color:var(--dim);padding:10px 16px;border-bottom:1px solid var(--line)}
  tbody td{padding:14px 16px;border-bottom:1px solid var(--line);vertical-align:top}
  tbody tr:hover{background:#12151d}
  .col1{width:30%} .col2{width:22%} .col3{width:22%} .col4{width:26%}
  .aatarget{display:inline-block;margin-top:6px;font-size:11.5px;font-weight:600;color:var(--gold);
    border:1px solid var(--line);border-radius:999px;padding:2px 9px;background:var(--chip)}
  a.link{color:#7db4ff;text-decoration:none;font-weight:600;word-break:break-word}
  a.link:hover{text-decoration:underline}
  .about{color:var(--text);margin-top:5px}
  .note{color:var(--dim);font-style:italic;margin-top:4px;font-size:12.5px}
  .when{color:var(--dim);font-size:11px;margin-top:6px}
  .chan{display:inline-block;font-size:10px;color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:1px 7px;margin-left:6px}
  .fit{display:inline-block;font-weight:700;border-radius:6px;padding:2px 9px;font-size:12px}
  .fit.Yes{background:rgba(46,204,113,.15);color:var(--yes)} .fit.Maybe{background:rgba(224,176,0,.15);color:var(--maybe)}
  .fit.No{background:rgba(224,90,90,.15);color:var(--no)} .fit\.\?{background:var(--chip);color:var(--dim)}
  .reason{margin-top:6px;color:var(--text)}
  .step{display:inline-block;border-radius:6px;padding:3px 10px;font-size:12px;font-weight:600;border:1px solid var(--line);background:var(--chip)}
  .step.build{border-color:#3b6fd0;color:#8fb4ff} .step.skill{border-color:#8a5cd0;color:#c3a6ff}
  .step.agent{border-color:#c9a227;color:#e6c65a} .step.skip{color:var(--dim)}
  .detail{margin-top:6px;color:var(--dim)}
  .pending{color:var(--dim);font-style:italic}
  .empty{padding:40px;text-align:center;color:var(--dim)}
  .search{background:var(--chip);border:1px solid var(--line);border-radius:999px;color:var(--text);
    padding:5px 13px;font:13px inherit;min-width:210px;outline:none}
  .search:focus{border-color:var(--gold)}
  .search::placeholder{color:var(--dim)}
  .filters{display:inline-flex;gap:6px}
  .filters button{background:var(--chip);color:var(--dim);border:1px solid var(--line);border-radius:999px;
    padding:3px 11px;font-size:12px;cursor:pointer}
  .filters button.on{background:var(--gold);color:#1a1a1a;border-color:var(--gold);font-weight:700}
  /* per-card action buttons — one row of actions per card, matched to the step colorway */
  .actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
  .act{appearance:none;cursor:pointer;font:inherit;font-weight:700;font-size:12.5px;line-height:1;
    border-radius:7px;padding:8px 12px;border:1px solid transparent;color:#0f1117;transition:filter .12s,transform .05s}
  .act:hover{filter:brightness(1.08)} .act:active{transform:translateY(1px)}
  .act.build{background:#3b6fd0;color:#fff} .act.skill{background:#8a5cd0;color:#fff}
  .act.agent{background:var(--gold);color:#1a1a1a}
  .act.open{background:transparent;color:#7db4ff;border-color:var(--line);font-weight:600}
  .act.skip{background:var(--chip);color:var(--dim);border-color:var(--line);font-weight:600;cursor:default}
  .act.copied{background:var(--yes)!important;color:#0f1117!important}
  /* toast */
  #toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(20px);
    background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--yes);
    color:var(--text);padding:11px 16px;border-radius:8px;font-size:13px;max-width:min(560px,90vw);
    box-shadow:0 8px 30px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:50}
  #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
  #toast b{color:var(--gold)} #toast code{color:var(--dim);font-size:11.5px;word-break:break-all}
  #accel{background:linear-gradient(135deg,#6d28d9,#2563eb);color:#fff;border:0;border-radius:8px;
    padding:8px 14px;font-weight:600;font-size:13px;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,.25);margin-right:14px}
  #accel:hover{filter:brightness(1.08)} #accel:active{transform:translateY(1px)} #accel:disabled{opacity:.6;cursor:default}
</style>
</head>
<body>
<header>
  <h1><b>Idea Board</b> — Slack feed triage</h1>
  <span class="meta" id="stat">loading…</span>
  <input type="search" id="q" class="search" placeholder="Search ideas, links, reasons, targets…" autocomplete="off" spellcheck="false">
  <span class="filters" id="filters">
    <button data-f="actionable" class="on">Actionable</button>
    <button data-f="yes">Yes only</button>
    <button data-f="aayes" title="Only ideas judged a Yes for an Agent Abrams build / existing project">AA · Yes</button>
    <button data-f="aafit" title="Ideas that fit an Agent Abrams build / project — Yes or Maybe">AA · fit</button>
    <button data-f="all">All</button>
  </span>
  <span class="spacer"></span>
  <button id="accel" title="Launch the Claude web-dev accelerator in a new terminal to rapidly prototype & launch a client project" onclick="launchAccelerator(this)">🚀 Launch Web-Dev Accelerator</button>
  <span class="live"><span class="dot"></span> live · auto-refresh 10m</span>
</header>
<table>
  <thead><tr>
    <th class="col1">1 · Link &amp; what the idea is</th>
    <th class="col2">2 · Good for your builds? Why</th>
    <th class="col3">3 · Good for an Agent Abrams build / project?</th>
    <th class="col4">4 · Next step</th>
  </tr></thead>
  <tbody id="rows"><tr><td colspan="4" class="empty">Loading your feed…</td></tr></tbody>
</table>
<div id="toast" role="status" aria-live="polite"></div>
<script>
const stepClass = s => /build new project/i.test(s)?'build':/skill/i.test(s)?'skill':/agent/i.test(s)?'agent':'skip';
const esc = s => (s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// Build a ready-to-run Claude Code kickoff command for an idea's next_step. SAFE — this is
// only a STRING copied to the clipboard; nothing here shells out or executes anything.
function kickoffCmd(i){
  const from = i.title ? `${i.title} — ${i.url}` : i.url;
  const d = (i.next_detail||i.about||i.title||'this idea').replace(/"/g,'\\"');
  const s = i.next_step||'';
  if(/build new project/i.test(s)) return `claude "Build a new project: ${d} (from idea: ${from})"`;
  if(/skill/i.test(s))            return `claude "/skill-creator  Create a new skill with its own git repo: ${d} (from idea: ${from})"`;
  if(/agent/i.test(s))            return `claude "Create a new agent: ${d} (from idea: ${from})"`;
  return '';
}
let TOAST_T;
function toast(html){
  const el=document.getElementById('toast'); el.innerHTML=html; el.classList.add('show');
  clearTimeout(TOAST_T); TOAST_T=setTimeout(()=>el.classList.remove('show'),4200);
}
async function copyText(t){
  try{ await navigator.clipboard.writeText(t); return true; }
  catch{ try{ const ta=document.createElement('textarea'); ta.value=t; ta.style.position='fixed'; ta.style.opacity='0';
    document.body.appendChild(ta); ta.select(); const ok=document.execCommand('copy'); ta.remove(); return ok; }catch{ return false; } }
}
async function copyAction(btn){
  const cmd=btn.dataset.cmd, label=btn.dataset.label;
  const ok=await copyText(cmd);
  if(ok){ toast(`<b>${esc(label)}</b> command copied — paste into a Claude Code session:<br><code>${esc(cmd)}</code>`); }
  else { toast('Could not access clipboard — command:<br><code>'+esc(cmd)+'</code>'); }
}
// Actually LAUNCH a Claude session in a new terminal via the backend.
// Guarded three ways so a click-storm (e.g. a "click every control" verification pass) can't open
// hundreds of terminals: (1) a native confirm() — automated click-through agents don't accept it,
// so they never reach the spawn; (2) a durable module-level in-flight lock keyed by id|step that
// survives re-render (the per-button `disabled` flag does not — render() replaces the node); (3) the
// POST carries confirm:true, which the server now requires before it will spawn anything.
const INFLIGHT = new Set();
async function launchAction(id, step, label, btn){
  const key=(id||'')+'|'+step;
  if(INFLIGHT.has(key)) return; // a launch for this id+step is already running
  if(!confirm(`Launch a REAL Claude Code session in a new iTerm2 tab?\n\n${label||step}`)) return;
  INFLIGHT.add(key);
  if(btn){ btn.classList.add('copied'); var prev=btn.textContent; btn.textContent='launching…'; btn.disabled=true; }
  try{
    const r=await fetch('/api/build',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,step,confirm:true})});
    const j=await r.json();
    if(j.launched){ toast(`🚀 <b>${esc(label||step)}</b> launched in a new iTerm2 tab<br><code>${esc((j.prompt||'').slice(0,160))}…</code>`); }
    else if(j.throttled){ toast('⏳ Throttled — '+esc(j.reason||'too many launches, try again shortly')); }
    else { toast('Launch failed: '+esc(j.error||'unknown')); }
  }catch(err){ toast('Launch failed: '+esc(String(err.message||err))); }
  finally{ INFLIGHT.delete(key); if(btn && btn.isConnected){ btn.classList.remove('copied'); btn.textContent=prev; btn.disabled=false; } }
}
// Wire clicks via delegation so re-renders never lose handlers.
// Click = launch a real session · Shift-click = copy the command to paste manually.
document.addEventListener('click', e=>{
  const b=e.target.closest('.act[data-cmd]'); if(!b) return; e.preventDefault();
  if(e.shiftKey || !b.dataset.id) return copyAction(b);
  launchAction(b.dataset.id, b.dataset.step||b.dataset.label, b.dataset.label, b);
});
// Header button — launch the web-dev accelerator directly (no idea seed).
async function launchAccelerator(btn){ return launchAction('', 'Build new project', 'Web-Dev Accelerator', btn); }
function fmt(iso){try{return new Date(iso).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}catch{return iso}}
let LAST = { ideas:[], model:'' }, FILTER = 'actionable', SEARCH = '';
// Free-text search over every caption field the model produced for an idea.
function matchesSearch(i){
  if(!SEARCH) return true;
  const hay = [i.title,i.about,i.text,i.note,i.url,i.channel,i.fit,i.fit_reason,
    i.aa_fit,i.aa_target,i.aa_reason,i.next_step,i.next_detail].filter(Boolean).join(' ').toLowerCase();
  return hay.includes(SEARCH);
}
function passes(i){
  if(!matchesSearch(i)) return false; // search gates first, independent of filter/pending
  if(FILTER==='all') return true;
  if(i.pending) return true; // whole record not judged yet
  if(FILTER==='aayes' || FILTER==='aafit'){
    if(i.aa_fit===undefined) return true; // AA verdict still backfilling — keep visible
    return FILTER==='aayes' ? i.aa_fit==='Yes' : (i.aa_fit==='Yes' || i.aa_fit==='Maybe');
  }
  if(FILTER==='yes') return i.fit==='Yes';
  return i.fit!=='No' && !/^skip$/i.test(i.next_step||''); // actionable
}
document.getElementById('filters').addEventListener('click', e=>{
  if(e.target.tagName!=='BUTTON') return;
  FILTER=e.target.dataset.f;
  [...document.querySelectorAll('#filters button')].forEach(b=>b.classList.toggle('on', b.dataset.f===FILTER));
  render();
});
document.getElementById('q').addEventListener('input', e=>{ SEARCH=(e.target.value||'').trim().toLowerCase(); render(); });
async function load(){
  try{
    const r = await fetch('/api/ideas'); LAST = await r.json(); render();
  }catch(e){ document.getElementById('stat').textContent='error: '+e.message; }
}
function render(){
    const d = LAST;
    const shown = d.ideas.filter(passes);
    document.getElementById('stat').textContent =
      `${shown.length} shown / ${d.count} total · ${d.pending} analyzing · ${d.model}`;
    const rows = document.getElementById('rows');
    if(!shown.length){ rows.innerHTML='<tr><td colspan=4 class=empty>Nothing in this filter yet. Drop an x.com/GitHub link in #claude-to-steve or #claude-chat.</td></tr>'; return; }
    rows.innerHTML = shown.map(i=>{
      const host = (()=>{try{return new URL(i.url).hostname.replace('www.','')}catch{return i.url}})();
      const c2 = i.pending ? '<span class="pending">analyzing…</span>'
        : `<span class="fit ${i.fit}">${esc(i.fit)}</span><div class="reason">${esc(i.fit_reason)}</div>`;
      // Agent Abrams / existing-project fit. aa_fit may be undefined on records enriched
      // before this column existed — those show "analyzing…" until the backfill fills them.
      const cAA = (i.pending || i.aa_fit===undefined) ? '<span class="pending">analyzing…</span>'
        : `<span class="fit ${i.aa_fit}">${esc(i.aa_fit)}</span>`
          + (i.aa_target && i.aa_target!=='—' ? `<div class="aatarget">${esc(i.aa_target)}</div>` : '')
          + (i.aa_reason ? `<div class="reason">${esc(i.aa_reason)}</div>` : '');
      const c3 = i.pending ? '<span class="pending">…</span>'
        : (()=>{
            const step = esc(i.next_step||''), sc = stepClass(i.next_step);
            const cmd = kickoffCmd(i);
            const isSkip = sc==='skip' || !cmd;
            const primary = isSkip
              ? `<span class="act skip" title="${esc(i.next_detail||'No action for this idea')}">Skip</span>`
              : `<button class="act ${sc}" data-id="${esc(i.id)}" data-step="${esc(i.next_step||'')}" data-cmd="${esc(cmd)}" data-label="${step}" title="Click to LAUNCH a Claude session in a new terminal · Shift-click to copy the command instead">${step} ▶</button>`;
            const open = i.url ? `<a class="act open" href="${esc(i.url)}" target="_blank" rel="noopener noreferrer">Open link ↗</a>` : '';
            return `<span class="step ${sc}">${step}</span>`
              + `<div class="detail">${esc(i.next_detail)}</div>`
              + `<div class="actions">${primary}${open}</div>`;
          })();
      return `<tr>
        <td class="col1">
          <a class="link" href="${esc(i.url)}" target="_blank" rel="noopener noreferrer">${esc(i.title||host)} ↗</a>
          <span class="chan">${esc(i.channel)}</span>
          <div class="about">${esc(i.about||i.text||'')}</div>
          ${i.note?`<div class="note">“${esc(i.note.slice(0,140))}”</div>`:''}
          <div class="when">🕓 ${fmt(i.when)}</div>
        </td>
        <td class="col2">${c2}</td>
        <td class="col3">${cAA}</td>
        <td class="col4">${c3}</td>
      </tr>`;
    }).join('');
}
load(); setInterval(load, 600000); // 10 minutes
</script>
</body>
</html>