← 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>
| 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,'"')}"></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>