← back to Dw Domain Launcher

public/index.html

466 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DW Domain Launcher — 151-domain cleanup</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
  :root {
    --bg:#0f0f10; --panel:#17181a; --ink:#f0ece4; --ink-soft:#a8a39a;
    --line:rgba(245,241,232,.10); --accent:#c89060;
    --launch:#3aa672; --delete:#d65a4a; --undecided:#777;
  }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--ink);
         font:14px/1.45 -apple-system, "SF Pro Text", system-ui, sans-serif; }
  header {
    position: sticky; top:0; z-index:50;
    background: rgba(15,15,16,0.92); backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--line);
    padding: 14px 22px; display:flex; align-items:center; gap:18px;
    flex-wrap: wrap;
  }
  header h1 { margin:0; font: 600 18px/1.2 ui-serif, Georgia, serif; letter-spacing:0; }
  header .counts { display:flex; gap:12px; flex-wrap:wrap; font-size: 12.5px; color: var(--ink-soft); }
  header .counts span b { color: var(--ink); font-weight:600; }
  header .counts .launch b { color: var(--launch); }
  header .counts .delete b { color: var(--delete); }
  header .counts .undecided b { color: var(--undecided); }

  .controls { display:flex; gap:8px; flex-wrap:wrap; padding: 12px 22px; border-bottom: 1px solid var(--line); }
  .controls input[type=search] {
    flex: 1 1 280px; min-width: 240px;
    background: var(--panel); color: var(--ink);
    border:1px solid var(--line); border-radius: 8px;
    padding: 8px 12px; font-size: 14px;
  }
  .controls select, .controls button {
    background: var(--panel); color: var(--ink);
    border:1px solid var(--line); border-radius: 8px;
    padding: 8px 12px; font-size: 13px; cursor: pointer;
  }
  .controls button:hover, .controls select:hover { background: #1f2024; }
  .controls .right { margin-left: auto; display:flex; gap:8px; }
  .controls a.dl { text-decoration:none; color: var(--accent); padding:8px 12px; border:1px solid var(--line); border-radius:8px; }

  .bulk { padding: 10px 22px; background: rgba(200,144,96,0.08); border-bottom:1px solid var(--line); display:none; gap:10px; align-items:center; flex-wrap:wrap; }
  .bulk.show { display:flex; }
  .bulk b { color: var(--accent); }

  table { width:100%; border-collapse: collapse; }
  th, td { padding: 8px 12px; text-align:left; border-bottom: 1px solid var(--line); font-size: 13px; vertical-align: middle; }
  th { position:sticky; top: 0; background: var(--panel); color: var(--ink-soft); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
  tr:hover { background: rgba(245,241,232,0.03); }
  td.domain { font: 500 14px/1.2 ui-monospace, "SF Mono", Menlo, monospace; }
  td.domain a { color: var(--accent); text-decoration: none; }
  td.domain a:hover { text-decoration: underline; }
  td.flags { font-size: 11px; color: var(--ink-soft); }
  td.flags .yes { color: var(--launch); }
  td.flags .no  { color: #555; }

  .pill-group { display:flex; gap:4px; }
  .pill {
    padding: 4px 10px; border-radius: 999px; cursor: pointer;
    font-size: 12px; font-weight: 500;
    background: transparent; border: 1px solid var(--line); color: var(--ink-soft);
    transition: all 120ms;
  }
  .pill:hover { background: rgba(245,241,232,0.06); }
  .pill.active.launch { background: var(--launch); border-color: var(--launch); color: #fff; }
  .pill.active.delete { background: var(--delete); border-color: var(--delete); color: #fff; }
  .pill.active.undecided { background: #444; border-color: #444; color: #ddd; }

  select.cat {
    background: var(--panel); color: var(--ink);
    border:1px solid var(--line); border-radius: 6px;
    padding: 4px 8px; font-size: 12.5px;
    min-width: 130px;
  }
  select.cat:focus { outline: 1px solid var(--accent); }

  input.note {
    background: var(--panel); color: var(--ink);
    border: 1px solid var(--line); border-radius: 6px;
    padding: 4px 8px; font-size: 12px; width: 100%; min-width: 120px;
  }

  input.sel { width: 16px; height: 16px; cursor: pointer; }
  th input.sel-all { width: 16px; height: 16px; }

  .toast { position: fixed; bottom: 20px; right: 20px; background:#1f2024; color:var(--ink); padding:10px 14px; border-radius:8px; border:1px solid var(--line); font-size:13px; opacity:0; pointer-events:none; transition:opacity 240ms; z-index:100; }
  .toast.show { opacity:1; }

  footer { padding: 18px 22px; color: var(--ink-soft); font-size: 12px; border-top: 1px solid var(--line); }
  footer code { background: var(--panel); padding: 2px 6px; border-radius: 4px; }
</style>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  </head>
<body>

<header>
  <h1>DW Domain Launcher</h1>
  <div class="counts" id="counts">loading...</div>
</header>

<div class="controls">
  <input type="search" id="q" placeholder="filter domains... (e.g. 'wall', '1920', 'forza')" autocomplete="off">
  <select id="filterDecision">
    <option value="">all decisions</option>
    <option value="launch">launch</option>
    <option value="delete">delete</option>
    <option value="undecided">undecided</option>
  </select>
  <select id="filterCategory">
    <option value="">all categories</option>
  </select>
  <select id="filterHas">
    <option value="">all</option>
    <option value="hasProject">has /Projects dir</option>
    <option value="noProject">no project dir</option>
    <option value="hasConfig">has config</option>
  </select>
  <div class="right">
    <button id="refreshGD" style="background:var(--accent);border:0;color:#0f0f10;font-weight:600">Refresh GoDaddy</button>
    <a class="dl" href="/api/export.csv" download>Download CSV</a>
    <a class="dl" href="/api/delete-manifest" target="_blank" rel="noopener noreferrer">Delete manifest</a>
    <a class="dl" href="/api/launch-manifest" target="_blank" rel="noopener noreferrer">Launch manifest</a>
  </div>
</div>

<div class="bulk" id="bulkBar">
  <b id="bulkCount">0</b> selected →
  set decision to:
  <button data-bulk-decision="launch">launch</button>
  <button data-bulk-decision="delete">delete</button>
  <button data-bulk-decision="undecided">undecided</button>
  &nbsp;|&nbsp; set category to:
  <select id="bulkCategory"><option value="">(pick)</option></select>
  <button id="bulkCatApply">apply category</button>
  <button id="bulkClear" style="margin-left:auto">clear selection</button>
</div>

<table>
  <thead>
    <tr>
      <th><input type="checkbox" class="sel-all" id="selAll"></th>
      <th class="sortable" data-sort="domain"     title="Click to sort">Domain     <span class="sort-ind"></span></th>
      <th class="sortable" data-sort="category"   title="Click to sort">Category   <span class="sort-ind"></span></th>
      <th class="sortable" data-sort="decision"   title="Click to sort">Decision   <span class="sort-ind"></span></th>
      <th class="sortable" data-sort="flags"      title="Click to sort">Flags      <span class="sort-ind"></span></th>
      <th class="sortable" data-sort="expires"    title="Click to sort">Expires    <span class="sort-ind"></span></th>
      <th class="sortable" data-sort="note"       title="Click to sort">Note       <span class="sort-ind"></span></th>
    </tr>
  </thead>
  <tbody id="tbody"></tbody>
</table>
<style>
  th.sortable { cursor: pointer; user-select: none; }
  th.sortable:hover { background: rgba(255,255,255,0.04); }
  th.sortable .sort-ind { display:inline-block; min-width:14px; color:var(--accent,#8ab4ff); font-size:11px; opacity:0.7; }
  th.sortable.is-active .sort-ind { opacity:1; }
</style>

<footer>
  Decisions saved to <code>data/decisions.json</code>.
  Delete manifest is <b>dry-run only</b> — no files removed until you run the commands manually.
  Launch manifest lists domains marked <span style="color:var(--launch)">launch</span> with project-dir state.
</footer>

<div class="toast" id="toast"></div>

<script>
let DATA = { rows: [], counts: {} };
let CATEGORIES = [];
const SELECTED = new Set();

// Sort state — persisted across reloads. Default: domain ascending.
const SORT = (() => {
  try { return JSON.parse(localStorage.getItem('dwLauncherSort')) || { col: 'domain', dir: 1 }; }
  catch { return { col: 'domain', dir: 1 }; }
})();
function saveSort() { try { localStorage.setItem('dwLauncherSort', JSON.stringify(SORT)); } catch {} }

const DECISION_ORDER = { launch: 0, undecided: 1, delete: 2 };
function flagsScore(r) {
  // Compose a stable comparable from the 4 flag dots so the column sorts
  // intuitively: best-state first (has-project + has-config + renews + locked).
  return (r.hasProjectDir ? 1 : 0) * 8 +
         (r.hasConfig     ? 1 : 0) * 4 +
         (r.renewAuto === false ? 0 : 1) * 2 +
         (r.locked === false ? 0 : 1) * 1;
}
function cellValueForSort(row, col) {
  switch (col) {
    case 'domain':   return (row.domain || '').toLowerCase();
    case 'category': return (row.category || '~').toLowerCase();   // "~" pushes blank to end
    case 'decision': return DECISION_ORDER[row.decision] ?? 9;
    case 'flags':    return -flagsScore(row);                       // negative so "more flags" comes first when asc
    case 'expires':  return row.expires || '9999-12-31';
    case 'note':     return (row.note || '~').toLowerCase();
    default:         return (row.domain || '').toLowerCase();
  }
}
function applySort(rows) {
  const { col, dir } = SORT;
  return rows.slice().sort((a, b) => {
    const av = cellValueForSort(a, col);
    const bv = cellValueForSort(b, col);
    if (av < bv) return -1 * dir;
    if (av > bv) return  1 * dir;
    // Tie-breaker — always domain asc — so order is deterministic on reload.
    return (a.domain || '').toLowerCase().localeCompare((b.domain || '').toLowerCase());
  });
}
function paintSortIndicators() {
  document.querySelectorAll('th.sortable').forEach(th => {
    const ind = th.querySelector('.sort-ind');
    const isActive = th.dataset.sort === SORT.col;
    th.classList.toggle('is-active', isActive);
    ind.textContent = isActive ? (SORT.dir === 1 ? '▲' : '▼') : '';
  });
}

const $ = sel => document.querySelector(sel);
const $$ = sel => document.querySelectorAll(sel);
const tbody = $('#tbody');

function toast(msg) {
  const t = $('#toast'); t.textContent = msg; t.classList.add('show');
  clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove('show'), 1600);
}

async function fetchCategories() {
  try {
    const r = await fetch('/api/categories');
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const j = await r.json();
    CATEGORIES = Array.isArray(j.categories) ? j.categories : [];
  } catch (e) {
    CATEGORIES = [];
    toast('error loading categories');
  }
  const opts = '<option value="">(no category)</option>' + CATEGORIES.map(c => `<option value="${c}">${c}</option>`).join('');
  $('#filterCategory').innerHTML = '<option value="">all categories</option><option value="(none)">(no category)</option>' +
    CATEGORIES.map(c => `<option value="${c}">${c}</option>`).join('');
  $('#bulkCategory').innerHTML = opts;
}

async function fetchAll() {
  try {
    const r = await fetch('/api/domains');
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    DATA = await r.json();
  } catch (e) {
    $('#counts').textContent = 'error loading domains';
    toast('error loading domains');
    return;
  }
  render();
}

function passesFilter(row) {
  const q = $('#q').value.trim().toLowerCase();
  if (q && !row.domain.toLowerCase().includes(q) && !(row.note || '').toLowerCase().includes(q)) return false;
  const fd = $('#filterDecision').value;
  if (fd && row.decision !== fd) return false;
  const fc = $('#filterCategory').value;
  if (fc === '(none)' && row.category) return false;
  if (fc && fc !== '(none)' && row.category !== fc) return false;
  const fh = $('#filterHas').value;
  if (fh === 'hasProject' && !row.hasProjectDir) return false;
  if (fh === 'noProject' && row.hasProjectDir) return false;
  if (fh === 'hasConfig' && !row.hasConfig) return false;
  return true;
}

function render() {
  const c = DATA.counts || {};
  $('#counts').innerHTML = `
    <span><b>${c.total||0}</b> total</span>
    <span class="launch">launch <b>${c.launch||0}</b></span>
    <span class="delete">delete <b>${c.delete||0}</b></span>
    <span class="undecided">undecided <b>${c.undecided||0}</b></span>
    <span>${c.withProject||0} have /Projects</span>
    <span>${c.withConfig||0} have config</span>
  `;
  const rows = applySort(DATA.rows.filter(passesFilter));
  paintSortIndicators();
  tbody.innerHTML = rows.map(r => `
    <tr data-domain="${r.domain}">
      <td><input type="checkbox" class="sel" data-domain="${r.domain}" ${SELECTED.has(r.domain)?'checked':''}></td>
      <td class="domain">
        <a href="http://${r.domain}" target="_blank" rel="noopener noreferrer">${r.domain}</a>
      </td>
      <td>
        <select class="cat" data-domain="${r.domain}">
          <option value="">(none)</option>
          ${CATEGORIES.map(c => `<option value="${c}" ${r.category===c?'selected':''}>${c}</option>`).join('')}
        </select>
      </td>
      <td>
        <div class="pill-group" data-domain="${r.domain}">
          <button class="pill launch ${r.decision==='launch'?'active':''}" data-decision="launch">launch</button>
          <button class="pill delete ${r.decision==='delete'?'active':''}" data-decision="delete">delete</button>
          <button class="pill undecided ${r.decision==='undecided'?'active':''}" data-decision="undecided">undecided</button>
        </div>
      </td>
      <td class="flags">
        <span class="${r.hasProjectDir?'yes':'no'}">${r.hasProjectDir?'•proj':'-proj'}</span>
        <span class="${r.hasConfig?'yes':'no'}">${r.hasConfig?'•cfg':'-cfg'}</span>
        ${r.renewAuto===false ? '<span style="color:#d65a4a">•no-renew</span>' : ''}
        ${r.locked===false ? '<span style="color:#d65a4a">•unlocked</span>' : ''}
      </td>
      <td class="flags" style="font:11px ui-monospace,monospace">${r.expires ? r.expires.slice(0,10) : '—'}</td>
      <td><input class="note" data-domain="${r.domain}" placeholder="note..." value="${(r.note||'').replace(/"/g,'&quot;')}"></td>
    </tr>
  `).join('');
  updateBulkBar();
}

function updateBulkBar() {
  $('#bulkCount').textContent = SELECTED.size;
  $('#bulkBar').classList.toggle('show', SELECTED.size > 0);
}

async function setDecision(domain, decision) {
  const r = await fetch('/api/decision', {
    method:'POST',
    headers:{'content-type':'application/json'},
    body: JSON.stringify({ domain, decision })
  });
  if (!r.ok) return toast('error saving');
  const row = DATA.rows.find(x => x.domain === domain);
  if (row) row.decision = decision;
  DATA.counts = recomputeCounts();
  toast(`${decision} ← ${domain}`);
  render();
}

async function setCategory(domain, category) {
  const r = await fetch('/api/decision', {
    method:'POST',
    headers:{'content-type':'application/json'},
    body: JSON.stringify({ domain, category })
  });
  if (!r.ok) return toast('error saving');
  const row = DATA.rows.find(x => x.domain === domain);
  if (row) row.category = category;
  toast(`cat=${category||'(none)'} ← ${domain}`);
  render();
}

async function setNote(domain, note) {
  await fetch('/api/decision', {
    method:'POST',
    headers:{'content-type':'application/json'},
    body: JSON.stringify({ domain, note })
  });
  const row = DATA.rows.find(x => x.domain === domain);
  if (row) row.note = note;
}

function recomputeCounts() {
  const c = { total: DATA.rows.length, launch:0, delete:0, undecided:0, withProject:0, withConfig:0 };
  for (const r of DATA.rows) {
    c[r.decision] = (c[r.decision]||0) + 1;
    if (r.hasProjectDir) c.withProject++;
    if (r.hasConfig) c.withConfig++;
  }
  return c;
}

// ============ event delegation ============
tbody.addEventListener('click', (e) => {
  const pill = e.target.closest('.pill');
  if (pill) {
    const domain = pill.parentElement.dataset.domain;
    setDecision(domain, pill.dataset.decision);
    return;
  }
});
tbody.addEventListener('change', (e) => {
  const sel = e.target.closest('select.cat');
  if (sel) { setCategory(sel.dataset.domain, sel.value); return; }
  const cb = e.target.closest('input.sel');
  if (cb) {
    const dom = cb.dataset.domain;
    if (cb.checked) SELECTED.add(dom); else SELECTED.delete(dom);
    updateBulkBar();
    return;
  }
});
tbody.addEventListener('blur', (e) => {
  const note = e.target.closest('input.note');
  if (note) setNote(note.dataset.domain, note.value);
}, true);

['q','filterDecision','filterCategory','filterHas'].forEach(id => {
  $('#'+id).addEventListener('input', render);
  $('#'+id).addEventListener('change', render);
});

$('#selAll').addEventListener('change', (e) => {
  const visible = DATA.rows.filter(passesFilter).map(r => r.domain);
  if (e.target.checked) visible.forEach(d => SELECTED.add(d));
  else visible.forEach(d => SELECTED.delete(d));
  render();
});

// Sortable column headers. First click = asc by that col (or toggle dir if
// already active). localStorage persists choice across reloads.
document.querySelectorAll('th.sortable').forEach(th => {
  th.addEventListener('click', () => {
    const col = th.dataset.sort;
    if (SORT.col === col) SORT.dir = -SORT.dir;
    else { SORT.col = col; SORT.dir = 1; }
    saveSort();
    render();
  });
});

$('#bulkClear').addEventListener('click', () => { SELECTED.clear(); render(); });

$$('button[data-bulk-decision]').forEach(b => b.addEventListener('click', async () => {
  if (!SELECTED.size) return;
  const decision = b.dataset.bulkDecision;
  const r = await fetch('/api/bulk-decision', {
    method:'POST', headers:{'content-type':'application/json'},
    body: JSON.stringify({ domains:[...SELECTED], decision })
  });
  const j = await r.json();
  toast(`bulk: ${decision} × ${j.updated}`);
  await fetchAll();
}));

$('#bulkCatApply').addEventListener('click', async () => {
  if (!SELECTED.size) return;
  const category = $('#bulkCategory').value;
  await fetch('/api/bulk-decision', {
    method:'POST', headers:{'content-type':'application/json'},
    body: JSON.stringify({ domains:[...SELECTED], category })
  });
  toast(`bulk: cat=${category||'(none)'} × ${SELECTED.size}`);
  await fetchAll();
});

$('#refreshGD').addEventListener('click', async () => {
  $('#refreshGD').textContent = 'Refreshing...'; $('#refreshGD').disabled = true;
  try {
    const r = await fetch('/api/refresh-godaddy', { method:'POST' });
    const j = await r.json();
    toast(j.ok ? `GoDaddy: ${j.count} domains synced` : `error: ${j.error}`);
    await fetchAll();
  } finally {
    $('#refreshGD').textContent = 'Refresh GoDaddy'; $('#refreshGD').disabled = false;
  }
});

// init
(async () => {
  await fetchCategories();
  await fetchAll();
})();
</script>
</body>
</html>