mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 13:45:35 +02:00
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.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Cookie picker route handler — HTTP + Playwright glue
|
||||
*
|
||||
* Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
|
||||
* (decryption) and cookie-picker-ui.ts (HTML generation).
|
||||
*
|
||||
* Routes (no auth — localhost-only, accepted risk):
|
||||
* GET /cookie-picker → serves the picker HTML page
|
||||
* GET /cookie-picker/browsers → list installed browsers
|
||||
* GET /cookie-picker/domains → list domains + counts for a browser
|
||||
* POST /cookie-picker/import → decrypt + import cookies to Playwright
|
||||
* POST /cookie-picker/remove → clear cookies for domains
|
||||
* GET /cookie-picker/imported → currently imported domains + counts
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
// Tracks which domains were imported via the picker.
|
||||
// /imported only returns cookies for domains in this Set.
|
||||
// /remove clears from this Set.
|
||||
const importedDomains = new Set<string>();
|
||||
const importedCounts = new Map<string, number>();
|
||||
|
||||
// ─── JSON Helpers ───────────────────────────────────────────────
|
||||
|
||||
function jsonResponse(data: any, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, code: string, status = 400, action?: string): Response {
|
||||
return jsonResponse({ error: message, code, ...(action ? { action } : {}) }, status);
|
||||
}
|
||||
|
||||
// ─── Route Handler ──────────────────────────────────────────────
|
||||
|
||||
export async function handleCookiePickerRoute(
|
||||
url: URL,
|
||||
req: Request,
|
||||
bm: BrowserManager,
|
||||
): Promise<Response> {
|
||||
const pathname = url.pathname;
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// GET /cookie-picker — serve the picker UI
|
||||
if (pathname === '/cookie-picker' && req.method === 'GET') {
|
||||
const port = parseInt(url.port, 10) || 9400;
|
||||
const html = getCookiePickerHTML(port);
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cookie-picker/browsers — list installed browsers
|
||||
if (pathname === '/cookie-picker/browsers' && req.method === 'GET') {
|
||||
const browsers = findInstalledBrowsers();
|
||||
return jsonResponse({
|
||||
browsers: browsers.map(b => ({
|
||||
name: b.name,
|
||||
aliases: b.aliases,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cookie-picker/domains?browser=<name> — list domains + counts
|
||||
if (pathname === '/cookie-picker/domains' && req.method === 'GET') {
|
||||
const browserName = url.searchParams.get('browser');
|
||||
if (!browserName) {
|
||||
return errorResponse("Missing 'browser' parameter", 'missing_param');
|
||||
}
|
||||
const result = listDomains(browserName);
|
||||
return jsonResponse({
|
||||
browser: result.browser,
|
||||
domains: result.domains,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /cookie-picker/import — decrypt + import to Playwright session
|
||||
if (pathname === '/cookie-picker/import' && req.method === 'POST') {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON body', 'bad_request');
|
||||
}
|
||||
|
||||
const { browser, domains } = body;
|
||||
if (!browser) return errorResponse("Missing 'browser' field", 'missing_param');
|
||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||
return errorResponse("Missing or empty 'domains' array", 'missing_param');
|
||||
}
|
||||
|
||||
// Decrypt cookies from the browser DB
|
||||
const result = await importCookies(browser, domains);
|
||||
|
||||
if (result.cookies.length === 0) {
|
||||
return jsonResponse({
|
||||
imported: 0,
|
||||
failed: result.failed,
|
||||
domainCounts: {},
|
||||
message: result.failed > 0
|
||||
? `All ${result.failed} cookies failed to decrypt`
|
||||
: 'No cookies found for the specified domains',
|
||||
});
|
||||
}
|
||||
|
||||
// Add to Playwright context
|
||||
const page = bm.getPage();
|
||||
await page.context().addCookies(result.cookies);
|
||||
|
||||
// Track what was imported
|
||||
for (const domain of Object.keys(result.domainCounts)) {
|
||||
importedDomains.add(domain);
|
||||
importedCounts.set(domain, (importedCounts.get(domain) || 0) + result.domainCounts[domain]);
|
||||
}
|
||||
|
||||
console.log(`[cookie-picker] Imported ${result.count} cookies for ${Object.keys(result.domainCounts).length} domains`);
|
||||
|
||||
return jsonResponse({
|
||||
imported: result.count,
|
||||
failed: result.failed,
|
||||
domainCounts: result.domainCounts,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /cookie-picker/remove — clear cookies for domains
|
||||
if (pathname === '/cookie-picker/remove' && req.method === 'POST') {
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON body', 'bad_request');
|
||||
}
|
||||
|
||||
const { domains } = body;
|
||||
if (!domains || !Array.isArray(domains) || domains.length === 0) {
|
||||
return errorResponse("Missing or empty 'domains' array", 'missing_param');
|
||||
}
|
||||
|
||||
const page = bm.getPage();
|
||||
const context = page.context();
|
||||
for (const domain of domains) {
|
||||
await context.clearCookies({ domain });
|
||||
importedDomains.delete(domain);
|
||||
importedCounts.delete(domain);
|
||||
}
|
||||
|
||||
console.log(`[cookie-picker] Removed cookies for ${domains.length} domains`);
|
||||
|
||||
return jsonResponse({
|
||||
removed: domains.length,
|
||||
domains,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cookie-picker/imported — currently imported domains + counts
|
||||
if (pathname === '/cookie-picker/imported' && req.method === 'GET') {
|
||||
const entries: Array<{ domain: string; count: number }> = [];
|
||||
for (const domain of importedDomains) {
|
||||
entries.push({ domain, count: importedCounts.get(domain) || 0 });
|
||||
}
|
||||
entries.sort((a, b) => b.count - a.count);
|
||||
|
||||
return jsonResponse({
|
||||
domains: entries,
|
||||
totalDomains: entries.length,
|
||||
totalCookies: entries.reduce((sum, e) => sum + e.count, 0),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
} catch (err: any) {
|
||||
if (err instanceof CookieImportError) {
|
||||
return errorResponse(err.message, err.code, 400, err.action);
|
||||
}
|
||||
console.error(`[cookie-picker] Error: ${err.message}`);
|
||||
return errorResponse(err.message || 'Internal error', 'internal_error', 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user