mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
f7b95329c1
* Phase 2: Enhanced browser — dialog handling, upload, state checks, snapshots - CircularBuffer O(1) ring buffer for console/network/dialog (was O(n) array+shift) - Async buffer flush with Bun.write() (was appendFileSync) - Dialog auto-accept/dismiss with buffer + prompt text support - File upload command (upload <sel> <file...>) - Element state checks (is visible/hidden/enabled/disabled/checked/editable/focused) - Annotated screenshots with ref labels overlaid (-a flag) - Snapshot diffing against previous snapshot (-D flag) - Cursor-interactive element scan for non-ARIA clickables (-C flag) - Snapshot scoping depth limit (-d N flag) - Health check with page.evaluate + 2s timeout - Playwright error wrapping — actionable messages for AI agents - Fix useragent — context recreation preserves cookies/storage/URLs - wait --networkidle / --load / --domcontentloaded flags - console --errors filter (error + warning only) - cookie-import <json-file> with auto-fill domain from page URL - 166 integration tests (was ~63) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 2: Rewrite SKILL.md as QA playbook + command reference Reorient SKILL.md files from raw command reference to QA-first playbook with 10 workflow patterns (test user flows, verify deployments, dogfood features, responsive layouts, file upload, forms, dialogs, compare pages). Compact command reference tables at the bottom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Phase 3: /qa skill — systematic QA testing with health scores New /qa skill for systematic web app QA testing. Three modes: - full: 5-10 documented issues with screenshots and repro steps - quick: 30-second smoke test with health score - regression: compare against saved baseline Includes issue taxonomy (7 categories, 4 severity levels), structured report template, health score rubric (weighted across 7 categories), framework detection guidance (Next.js, Rails, WordPress, SPA). Also adds browse/bin/find-browse (DRY binary discovery using git rev-parse), .gstack/ to .gitignore, and updated TODO roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Bump to v0.3.0 — Phase 2 + Phase 3 changelog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: cookie-import-browser — Chromium cookie decryption module + tests Pure logic module for reading and decrypting cookies from macOS Chromium browsers (Comet, Chrome, Arc, Brave, Edge). Supports v10 AES-128-CBC encryption with macOS Keychain access, PBKDF2 key derivation, and per-browser key caching. 18 unit tests with encrypted cookie fixtures. * feat: cookie picker web UI + route handler Two-panel dark-theme picker served from the browse server. Left panel shows source browser domains with search and import buttons. Right panel shows imported domains with trash buttons. No cookie values exposed. 6 API endpoints, importedDomains Set tracking, inline clearCookies. * feat: wire cookie-import-browser into browse server Add cookie-picker route dispatch (no auth, localhost-only), add cookie-import-browser to WRITE_COMMANDS and CHAIN_WRITE, add serverPort property to BrowserManager, add write command with two modes (picker UI vs --domain direct import), update CLI help text. * chore: /setup-browser-cookies skill + docs (Phase 3.5) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: bump version and changelog (v0.3.1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: redact sensitive values from command output (PR #21) type no longer echoes text (reports character count), cookie redacts value with ****, header redacts Authorization/Cookie/X-API-Key/X-Auth-Token, storage set drops value, forms redacts password fields. Prevents secrets from persisting in LLM transcripts. 7 new tests. Credit: fredluz (PR #21) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: path traversal prevention for screenshot/pdf/eval (PR #26) Add validateOutputPath() for screenshot/pdf/responsive (restricts to /tmp and cwd) and validateReadPath() for eval (blocks .. sequences and absolute paths outside safe dirs). 7 new tests. Credit: Jah-yee (PR #26) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-install Playwright Chromium in setup (PR #22) Setup now verifies Playwright can launch Chromium, and auto-installs it via `bunx playwright install chromium` if missing. Exits non-zero if build or Chromium launch fails. Credit: AkbarDevop (PR #22) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * security: fix path validation bypass, CORS restriction, cookie-import path check - startsWith('/tmp') matched '/tmpevil' — now requires trailing slash - CORS Access-Control-Allow-Origin changed from * to http://127.0.0.1:<port> - cookie-import now validates file paths (was missing validateReadPath) - 3 new tests for prefix collision and cookie-import path traversal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review informational issues + add regression tests - Add cookie-import to CHAIN_WRITE set for chain command routing - Add path validation to snapshot -a -o output path - Fix package.json version to match 0.3.1 - Use crypto.randomUUID() for temp DB paths (unpredictable filenames) - Add regression tests for chain cookie-import and snapshot path validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add /qa, /setup-browser-cookies to README + update BROWSER.md - Add /qa and /setup-browser-cookies to skills table, install/update/uninstall blurbs - Add dedicated README sections for both new skills with usage examples - Update demo workflow to show cookie import → QA → browse flow - Update BROWSER.md: cookie import commands, new source files, test count (203) - Update skill count from 6 to 8 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: team-aware /retro v2.0 — per-person praise and growth opportunities - Identify current user via git config, orient narrative as "you" vs teammates - Add per-author metrics: commits, LOC, focus areas, commit type mix, sessions - New "Your Week" section with personal deep-dive for whoever runs the command - New "Team Breakdown" with per-person praise and growth opportunities - Track AI-assisted commits via Co-Authored-By trailers - Personal + team shipping streaks - Tone: praise like a 1:1, growth like investment advice, never compare negatively Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Conductor parallel sessions section to README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
542 lines
16 KiB
TypeScript
542 lines
16 KiB
TypeScript
/**
|
||
* Cookie picker UI — self-contained HTML page
|
||
*
|
||
* Dark theme, two-panel layout, vanilla HTML/CSS/JS.
|
||
* Left: source browser domains with search + import buttons.
|
||
* Right: imported domains with trash buttons.
|
||
* No cookie values exposed anywhere.
|
||
*/
|
||
|
||
export function getCookiePickerHTML(serverPort: number): string {
|
||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||
|
||
return `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Cookie Import — gstack browse</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
background: #0a0a0a;
|
||
color: #e0e0e0;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ─── Header ──────────────────────────── */
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 24px;
|
||
border-bottom: 1px solid #222;
|
||
background: #0f0f0f;
|
||
}
|
||
.header h1 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
}
|
||
.header .port {
|
||
font-size: 12px;
|
||
color: #666;
|
||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||
}
|
||
|
||
/* ─── Layout ──────────────────────────── */
|
||
.container {
|
||
display: flex;
|
||
height: calc(100vh - 53px);
|
||
}
|
||
.panel {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.panel-left {
|
||
border-right: 1px solid #222;
|
||
}
|
||
.panel-header {
|
||
padding: 16px 20px 12px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: #888;
|
||
}
|
||
|
||
/* ─── Browser Pills ───────────────────── */
|
||
.browser-pills {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 0 20px 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pill {
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
border: 1px solid #333;
|
||
background: #1a1a1a;
|
||
color: #aaa;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.pill:hover { border-color: #555; color: #ddd; }
|
||
.pill.active {
|
||
border-color: #4ade80;
|
||
background: #0a2a14;
|
||
color: #4ade80;
|
||
}
|
||
.pill .dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: #4ade80;
|
||
}
|
||
|
||
/* ─── Search ──────────────────────────── */
|
||
.search-wrap {
|
||
padding: 0 20px 12px;
|
||
}
|
||
.search-input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid #333;
|
||
background: #141414;
|
||
color: #e0e0e0;
|
||
font-size: 13px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.search-input::placeholder { color: #555; }
|
||
.search-input:focus { border-color: #555; }
|
||
|
||
/* ─── Domain List ─────────────────────── */
|
||
.domain-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0 12px;
|
||
}
|
||
.domain-list::-webkit-scrollbar { width: 6px; }
|
||
.domain-list::-webkit-scrollbar-track { background: transparent; }
|
||
.domain-list::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
|
||
|
||
.domain-row {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
transition: background 0.1s;
|
||
gap: 8px;
|
||
}
|
||
.domain-row:hover { background: #1a1a1a; }
|
||
.domain-name {
|
||
flex: 1;
|
||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||
font-size: 13px;
|
||
color: #ccc;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.domain-count {
|
||
font-size: 12px;
|
||
color: #666;
|
||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||
min-width: 28px;
|
||
text-align: right;
|
||
}
|
||
.btn-add, .btn-trash {
|
||
width: 28px; height: 28px;
|
||
border-radius: 6px;
|
||
border: 1px solid #333;
|
||
background: #1a1a1a;
|
||
color: #888;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.btn-add:hover { border-color: #4ade80; color: #4ade80; background: #0a2a14; }
|
||
.btn-trash:hover { border-color: #f87171; color: #f87171; background: #2a0a0a; }
|
||
.btn-add:disabled, .btn-trash:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
pointer-events: none;
|
||
}
|
||
.btn-add.imported {
|
||
border-color: #333;
|
||
color: #4ade80;
|
||
background: transparent;
|
||
cursor: default;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* ─── Footer ──────────────────────────── */
|
||
.panel-footer {
|
||
padding: 12px 20px;
|
||
border-top: 1px solid #222;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
/* ─── Imported Panel ──────────────────── */
|
||
.imported-empty {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #444;
|
||
font-size: 13px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ─── Banner ──────────────────────────── */
|
||
.banner {
|
||
padding: 10px 20px;
|
||
font-size: 13px;
|
||
display: none;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.banner.error {
|
||
background: #1a0a0a;
|
||
border-bottom: 1px solid #3a1111;
|
||
color: #f87171;
|
||
}
|
||
.banner.info {
|
||
background: #0a1a2a;
|
||
border-bottom: 1px solid #112233;
|
||
color: #60a5fa;
|
||
}
|
||
.banner .banner-text { flex: 1; }
|
||
.banner .banner-close, .banner .banner-retry {
|
||
background: none;
|
||
border: 1px solid currentColor;
|
||
color: inherit;
|
||
padding: 3px 10px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ─── Spinner ─────────────────────────── */
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 14px; height: 14px;
|
||
border: 2px solid #333;
|
||
border-top-color: #4ade80;
|
||
border-radius: 50%;
|
||
animation: spin 0.6s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.loading-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px;
|
||
gap: 10px;
|
||
color: #666;
|
||
font-size: 13px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header">
|
||
<h1>Cookie Import</h1>
|
||
<span class="port">localhost:${serverPort}</span>
|
||
</div>
|
||
|
||
<div id="banner" class="banner"></div>
|
||
|
||
<div class="container">
|
||
<!-- Left Panel: Source Browser -->
|
||
<div class="panel panel-left">
|
||
<div class="panel-header">Source Browser</div>
|
||
<div id="browser-pills" class="browser-pills"></div>
|
||
<div class="search-wrap">
|
||
<input type="text" class="search-input" id="search" placeholder="Search domains..." />
|
||
</div>
|
||
<div class="domain-list" id="source-domains">
|
||
<div class="loading-row"><span class="spinner"></span> Detecting browsers...</div>
|
||
</div>
|
||
<div class="panel-footer" id="source-footer"></div>
|
||
</div>
|
||
|
||
<!-- Right Panel: Imported -->
|
||
<div class="panel panel-right">
|
||
<div class="panel-header">Imported to Session</div>
|
||
<div class="domain-list" id="imported-domains">
|
||
<div class="imported-empty">No cookies imported yet</div>
|
||
</div>
|
||
<div class="panel-footer" id="imported-footer"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
const BASE = '${baseUrl}';
|
||
let activeBrowser = null;
|
||
let allDomains = [];
|
||
let importedSet = {}; // domain → count
|
||
let inflight = {}; // domain → true (prevents double-click)
|
||
|
||
const $pills = document.getElementById('browser-pills');
|
||
const $search = document.getElementById('search');
|
||
const $sourceDomains = document.getElementById('source-domains');
|
||
const $importedDomains = document.getElementById('imported-domains');
|
||
const $sourceFooter = document.getElementById('source-footer');
|
||
const $importedFooter = document.getElementById('imported-footer');
|
||
const $banner = document.getElementById('banner');
|
||
|
||
// ─── Banner ────────────────────────────
|
||
function showBanner(msg, type, retryFn) {
|
||
$banner.className = 'banner ' + type;
|
||
$banner.style.display = 'flex';
|
||
let html = '<span class="banner-text">' + escHtml(msg) + '</span>';
|
||
if (retryFn) {
|
||
html += '<button class="banner-retry" id="banner-retry">Retry</button>';
|
||
}
|
||
html += '<button class="banner-close" id="banner-close">×</button>';
|
||
$banner.innerHTML = html;
|
||
document.getElementById('banner-close').onclick = () => { $banner.style.display = 'none'; };
|
||
if (retryFn) {
|
||
document.getElementById('banner-retry').onclick = () => {
|
||
$banner.style.display = 'none';
|
||
retryFn();
|
||
};
|
||
}
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ─── API ────────────────────────────────
|
||
async function api(path, opts) {
|
||
const res = await fetch(BASE + '/cookie-picker' + path, opts);
|
||
const data = await res.json();
|
||
if (!res.ok) {
|
||
const err = new Error(data.error || 'Request failed');
|
||
err.code = data.code;
|
||
err.action = data.action;
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
// ─── Init ───────────────────────────────
|
||
async function init() {
|
||
try {
|
||
const [browserData, importedData] = await Promise.all([
|
||
api('/browsers'),
|
||
api('/imported'),
|
||
]);
|
||
|
||
// Populate imported state
|
||
for (const entry of importedData.domains) {
|
||
importedSet[entry.domain] = entry.count;
|
||
}
|
||
renderImported();
|
||
|
||
// Render browser pills
|
||
const browsers = browserData.browsers;
|
||
if (browsers.length === 0) {
|
||
$sourceDomains.innerHTML = '<div class="imported-empty">No Chromium browsers detected</div>';
|
||
return;
|
||
}
|
||
|
||
$pills.innerHTML = '';
|
||
browsers.forEach(b => {
|
||
const pill = document.createElement('button');
|
||
pill.className = 'pill';
|
||
pill.innerHTML = '<span class="dot"></span>' + escHtml(b.name);
|
||
pill.onclick = () => selectBrowser(b.name);
|
||
$pills.appendChild(pill);
|
||
});
|
||
|
||
// Auto-select first browser
|
||
selectBrowser(browsers[0].name);
|
||
} catch (err) {
|
||
showBanner(err.message, 'error', init);
|
||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load</div>';
|
||
}
|
||
}
|
||
|
||
// ─── Select Browser ────────────────────
|
||
async function selectBrowser(name) {
|
||
activeBrowser = name;
|
||
|
||
// Update pills
|
||
$pills.querySelectorAll('.pill').forEach(p => {
|
||
p.classList.toggle('active', p.textContent === name);
|
||
});
|
||
|
||
$sourceDomains.innerHTML = '<div class="loading-row"><span class="spinner"></span> Loading domains...</div>';
|
||
$sourceFooter.textContent = '';
|
||
$search.value = '';
|
||
|
||
try {
|
||
const data = await api('/domains?browser=' + encodeURIComponent(name));
|
||
allDomains = data.domains;
|
||
renderSourceDomains();
|
||
} catch (err) {
|
||
showBanner(err.message, 'error', err.action === 'retry' ? () => selectBrowser(name) : null);
|
||
$sourceDomains.innerHTML = '<div class="imported-empty">Failed to load domains</div>';
|
||
}
|
||
}
|
||
|
||
// ─── Render Source Domains ─────────────
|
||
function renderSourceDomains() {
|
||
const query = $search.value.toLowerCase();
|
||
const filtered = query
|
||
? allDomains.filter(d => d.domain.toLowerCase().includes(query))
|
||
: allDomains;
|
||
|
||
if (filtered.length === 0) {
|
||
$sourceDomains.innerHTML = '<div class="imported-empty">' +
|
||
(query ? 'No matching domains' : 'No cookie domains found') + '</div>';
|
||
$sourceFooter.textContent = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const d of filtered) {
|
||
const isImported = d.domain in importedSet;
|
||
const isInflight = inflight[d.domain];
|
||
html += '<div class="domain-row">';
|
||
html += '<span class="domain-name">' + escHtml(d.domain) + '</span>';
|
||
html += '<span class="domain-count">' + d.count + '</span>';
|
||
if (isInflight) {
|
||
html += '<span class="btn-add" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;"></span></span>';
|
||
} else if (isImported) {
|
||
html += '<span class="btn-add imported">✓</span>';
|
||
} else {
|
||
html += '<button class="btn-add" data-domain="' + escHtml(d.domain) + '" title="Import">+</button>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
$sourceDomains.innerHTML = html;
|
||
|
||
// Total counts
|
||
const totalDomains = allDomains.length;
|
||
const totalCookies = allDomains.reduce((s, d) => s + d.count, 0);
|
||
$sourceFooter.textContent = totalDomains + ' domains · ' + totalCookies.toLocaleString() + ' cookies';
|
||
|
||
// Click handlers
|
||
$sourceDomains.querySelectorAll('.btn-add[data-domain]').forEach(btn => {
|
||
btn.addEventListener('click', () => importDomain(btn.dataset.domain));
|
||
});
|
||
}
|
||
|
||
// ─── Import Domain ─────────────────────
|
||
async function importDomain(domain) {
|
||
if (inflight[domain] || domain in importedSet) return;
|
||
inflight[domain] = true;
|
||
renderSourceDomains();
|
||
|
||
try {
|
||
const data = await api('/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ browser: activeBrowser, domains: [domain] }),
|
||
});
|
||
|
||
if (data.domainCounts) {
|
||
for (const [d, count] of Object.entries(data.domainCounts)) {
|
||
importedSet[d] = (importedSet[d] || 0) + count;
|
||
}
|
||
}
|
||
renderImported();
|
||
} catch (err) {
|
||
showBanner('Import failed for ' + domain + ': ' + err.message, 'error',
|
||
err.action === 'retry' ? () => importDomain(domain) : null);
|
||
} finally {
|
||
delete inflight[domain];
|
||
renderSourceDomains();
|
||
}
|
||
}
|
||
|
||
// ─── Render Imported ───────────────────
|
||
function renderImported() {
|
||
const entries = Object.entries(importedSet).sort((a, b) => b[1] - a[1]);
|
||
|
||
if (entries.length === 0) {
|
||
$importedDomains.innerHTML = '<div class="imported-empty">No cookies imported yet</div>';
|
||
$importedFooter.textContent = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const [domain, count] of entries) {
|
||
const isInflight = inflight['remove:' + domain];
|
||
html += '<div class="domain-row">';
|
||
html += '<span class="domain-name">' + escHtml(domain) + '</span>';
|
||
html += '<span class="domain-count">' + count + '</span>';
|
||
if (isInflight) {
|
||
html += '<span class="btn-trash" disabled><span class="spinner" style="width:12px;height:12px;border-width:1.5px;border-top-color:#f87171;"></span></span>';
|
||
} else {
|
||
html += '<button class="btn-trash" data-domain="' + escHtml(domain) + '" title="Remove">🗑</button>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
$importedDomains.innerHTML = html;
|
||
|
||
const totalCookies = entries.reduce((s, e) => s + e[1], 0);
|
||
$importedFooter.textContent = entries.length + ' domains · ' + totalCookies.toLocaleString() + ' cookies imported';
|
||
|
||
// Click handlers
|
||
$importedDomains.querySelectorAll('.btn-trash[data-domain]').forEach(btn => {
|
||
btn.addEventListener('click', () => removeDomain(btn.dataset.domain));
|
||
});
|
||
}
|
||
|
||
// ─── Remove Domain ─────────────────────
|
||
async function removeDomain(domain) {
|
||
if (inflight['remove:' + domain]) return;
|
||
inflight['remove:' + domain] = true;
|
||
renderImported();
|
||
|
||
try {
|
||
await api('/remove', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ domains: [domain] }),
|
||
});
|
||
delete importedSet[domain];
|
||
renderImported();
|
||
renderSourceDomains(); // update checkmarks
|
||
} catch (err) {
|
||
showBanner('Remove failed for ' + domain + ': ' + err.message, 'error',
|
||
err.action === 'retry' ? () => removeDomain(domain) : null);
|
||
} finally {
|
||
delete inflight['remove:' + domain];
|
||
renderImported();
|
||
}
|
||
}
|
||
|
||
// ─── Search ────────────────────────────
|
||
$search.addEventListener('input', renderSourceDomains);
|
||
|
||
// ─── Start ─────────────────────────────
|
||
init();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>`;
|
||
}
|