function initDashboard() { function commaFormat(n) { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } function escapeHtml(text) { if (text == null) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // State let currentView = 'domains'; // 'domains', 'feeds', 'domain-feeds' let currentDomain = null; let infiniteScrollState = null; let isLoadingMore = false; let selectedTLDs = new Set(); // Empty means show all let allTLDs = []; // All available TLDs // Status colors and labels const statusConfig = { hold: { color: '#f90', bg: '#330', border: '#550', dimColor: '#664', dimBg: '#1a1a00', label: 'hold' }, skip: { color: '#f66', bg: '#400', border: '#600', dimColor: '#844', dimBg: '#200', label: 'skip' }, pass: { color: '#0f0', bg: '#040', border: '#060', dimColor: '#484', dimBg: '#020', label: 'pass' }, fail: { color: '#f00', bg: '#400', border: '#600', dimColor: '#333', dimBg: '#111', label: 'fail' }, drop: { color: '#f0f', bg: '#404', border: '#606', dimColor: '#848', dimBg: '#202', label: 'drop' } }; // Render status button group // For domains: pass, hold, skip (+ fail indicator or spacer) // For feeds: pass, hold, skip, drop (+ fail indicator or spacer) function renderStatusBtns(currentStatus, type, id, errorStatus) { const order = type === 'feed' ? ['pass', 'hold', 'skip', 'drop'] : ['pass', 'hold', 'skip']; const showFail = errorStatus === 'error' || errorStatus === 'dead'; let html = '
'; order.forEach((s, i) => { const cfg = statusConfig[s]; const isActive = currentStatus === s; const bg = isActive ? cfg.bg : cfg.dimBg; const color = isActive ? cfg.color : cfg.dimColor; const border = isActive ? cfg.border : '#333'; html += ``; }); // Fail indicator (not clickable) or hidden spacer for alignment const cfg = statusConfig.fail; const failBtnStyle = `padding: 2px 6px; font-size: 10px; font-family: monospace; background: ${cfg.bg}; border: 1px solid ${cfg.border}; border-radius: 3px; color: ${cfg.color}; cursor: default; margin-left: 1px; min-width: 26px; box-sizing: border-box;`; if (showFail) { html += ``; } else { // Hidden spacer - same style but invisible html += ``; } html += '
'; return html; } // Infinite scroll function setupInfiniteScroll(loadMoreFn) { infiniteScrollState = { loadMore: loadMoreFn, ended: false }; } function clearInfiniteScroll() { infiniteScrollState = null; } function checkInfiniteScroll() { if (!infiniteScrollState || infiniteScrollState.ended || isLoadingMore) return; const scrollBottom = window.scrollY + window.innerHeight; const docHeight = document.documentElement.scrollHeight; if (docHeight - scrollBottom < 300) { isLoadingMore = true; infiniteScrollState.loadMore().finally(() => { isLoadingMore = false; }); } } window.addEventListener('scroll', checkInfiniteScroll); // Render domain row with feeds function renderDomainRow(d) { const status = d.status || 'hold'; const hasError = !!d.last_error; const rowStyle = hasError ? 'opacity: 0.8;' : ''; let html = `
`; // Domain header row html += `
`; // Status buttons (pass/hold/skip + fail indicator if error) html += renderStatusBtns(status, 'domain', d.host, hasError ? 'error' : null); // Domain name (clickable to show feeds) html += `${escapeHtml(d.host)}`; // Error message if (d.last_error) { html += `${escapeHtml(d.last_error)}`; } else { html += ''; } // External link html += ``; // Drop button (only for skipped domains) if (status === 'skip') { html += ``; } html += '
'; // Feeds under this domain (hidden by default, toggled by clicking domain name) if (d.feeds && d.feeds.length > 0) { html += ''; } html += '
'; return html; } // Render feed row function renderFeedRow(f) { const status = f.publish_status || 'hold'; let html = `
`; html += `
`; // Language indicator (fixed width) const lang = f.language || ''; html += `${escapeHtml(lang)}`; // Status buttons (pass/hold/skip + fail indicator if errors) html += `
${renderStatusBtns(status, 'feed', f.url, f.status)}
`; // Feed info html += '
'; // Title or URL const title = f.title || f.url; html += `
${escapeHtml(title)}
`; // URL if different from title if (f.title) { html += `
${escapeHtml(f.url)}
`; } // Meta info let meta = []; if (f.type) meta.push(f.type); if (f.language) meta.push(f.language); if (f.item_count > 0) meta.push(commaFormat(f.item_count) + ' items'); if (f.status && f.status !== 'active') { const color = f.status === 'error' ? '#f66' : '#888'; meta.push(`${f.status}`); } if (meta.length > 0) { html += `
${meta.join(' · ')}
`; } html += '
'; // External link html += ``; html += '
'; // Collapsible detail section (hidden by default) html += ''; html += '
'; return html; } // Attach status button handlers function attachStatusHandlers(container) { container.querySelectorAll('.status-btn:not(.handled)').forEach(btn => { btn.classList.add('handled'); btn.addEventListener('click', async (e) => { e.stopPropagation(); const type = btn.dataset.type; const id = btn.dataset.id; const newStatus = btn.dataset.status; const group = btn.closest('.status-btn-group'); // Disable all buttons in group const allBtns = group.querySelectorAll('.status-btn'); allBtns.forEach(b => b.disabled = true); try { let endpoint; if (type === 'domain') { endpoint = `/api/setDomainStatus?host=${encodeURIComponent(id)}&status=${newStatus}`; } else { endpoint = `/api/setPublishStatus?url=${encodeURIComponent(id)}&status=${newStatus}`; } const resp = await fetch(endpoint); if (resp.ok) { // Update all buttons in group allBtns.forEach(b => { const s = b.dataset.status; const cfg = statusConfig[s]; const isActive = s === newStatus; b.style.background = isActive ? cfg.bg : cfg.dimBg; b.style.color = isActive ? cfg.color : cfg.dimColor; b.style.borderColor = isActive ? cfg.border : '#333'; }); // Update row data attribute const row = btn.closest('.domain-row, .feed-row'); if (row) row.dataset.status = newStatus; } } catch (err) { console.error('Status update failed:', err); } allBtns.forEach(b => b.disabled = false); }); }); } // Attach domain row handlers function attachDomainHandlers(container) { attachStatusHandlers(container); container.querySelectorAll('.domain-block:not(.block-handled)').forEach(block => { block.classList.add('block-handled'); const row = block.querySelector('.domain-row'); const host = block.dataset.host; // Click domain name to toggle inline feeds const domainName = row.querySelector('.domain-name'); const feedsDiv = block.querySelector('.domain-feeds'); if (domainName && feedsDiv) { domainName.addEventListener('click', () => { const isVisible = feedsDiv.style.display !== 'none'; feedsDiv.style.display = isVisible ? 'none' : 'block'; }); } // Hover effect on domain row row.addEventListener('mouseenter', () => row.style.background = '#1a1a1a'); row.addEventListener('mouseleave', () => row.style.background = 'transparent'); // Drop button handler (for skipped domains) const dropBtn = row.querySelector('.drop-btn'); if (dropBtn) { dropBtn.addEventListener('click', async (e) => { e.stopPropagation(); const host = dropBtn.dataset.host; if (!confirm(`Permanently delete all data for ${host}?\n\nThis will:\n- Delete all PDS accounts\n- Delete all feed items\n- Delete all feeds\n\nThis cannot be undone.`)) { return; } dropBtn.disabled = true; dropBtn.textContent = '...'; try { const resp = await fetch(`/api/dropDomain?host=${encodeURIComponent(host)}`); if (resp.ok) { const result = await resp.json(); // Update status to "drop" visually block.dataset.status = 'drop'; const statusGroup = row.querySelector('.status-btn-group'); if (statusGroup) { statusGroup.innerHTML = 'dropped'; } dropBtn.remove(); console.log('Drop result:', result); } else { alert('Drop failed: ' + await resp.text()); dropBtn.disabled = false; dropBtn.textContent = 'drop'; } } catch (err) { console.error('Drop failed:', err); alert('Drop failed: ' + err.message); dropBtn.disabled = false; dropBtn.textContent = 'drop'; } }); } // Handle inline feed clicks - toggle detail block.querySelectorAll('.inline-feed-block').forEach(feedBlock => { const title = feedBlock.querySelector('.feed-title'); const detailDiv = feedBlock.querySelector('.feed-detail'); const feedUrl = feedBlock.dataset.url; if (title && detailDiv) { title.addEventListener('click', () => { const isVisible = detailDiv.style.display !== 'none'; if (isVisible) { detailDiv.style.display = 'none'; } else { detailDiv.style.display = 'block'; if (!detailDiv.dataset.loaded) { detailDiv.dataset.loaded = 'true'; loadFeedDetail(feedUrl, detailDiv); } } }); } }); }); } // Load and render feed detail content async function loadFeedDetail(feedUrl, detailDiv) { detailDiv.innerHTML = '
Loading...
'; try { const [infoResp, itemsResp] = await Promise.all([ fetch('/api/feedInfo?url=' + encodeURIComponent(feedUrl)), fetch('/api/feedItems?url=' + encodeURIComponent(feedUrl) + '&limit=20') ]); const info = await infoResp.json(); const items = await itemsResp.json(); let html = ''; // Description if (info.description) { html += `
${escapeHtml(info.description)}
`; } // Info row let infoParts = []; if (info.type) infoParts.push(info.type); if (info.status) { const statusColor = info.status === 'active' ? '#0a0' : '#f66'; infoParts.push(`${info.status}`); } if (info.language) infoParts.push(info.language); if (info.itemCount) infoParts.push(commaFormat(info.itemCount) + ' items'); if (info.publishAccount) { infoParts.push(`@${info.publishAccount}`); } if (infoParts.length > 0) { html += `
${infoParts.join(' · ')}
`; } // Items if (items && items.length > 0) { html += '
'; items.forEach(item => { html += '
'; if (item.title && item.link) { html += `${escapeHtml(item.title)}`; } else if (item.title) { html += `${escapeHtml(item.title)}`; } if (item.pub_date) { const date = new Date(item.pub_date); html += `${date.toLocaleDateString()}`; } html += '
'; }); html += '
'; } else { html += '
No items
'; } detailDiv.innerHTML = html; } catch (err) { detailDiv.innerHTML = `
Error: ${escapeHtml(err.message)}
`; } } // Attach feed row handlers function attachFeedHandlers(container) { attachStatusHandlers(container); container.querySelectorAll('.feed-block:not(.block-handled)').forEach(block => { block.classList.add('block-handled'); const row = block.querySelector('.feed-row'); const detailDiv = block.querySelector('.feed-detail'); const feedUrl = block.dataset.url; // Click feed title to toggle detail const title = row.querySelector('.feed-title'); if (title) { title.addEventListener('click', () => { const isVisible = detailDiv.style.display !== 'none'; if (isVisible) { detailDiv.style.display = 'none'; } else { detailDiv.style.display = 'block'; // Load content if empty if (!detailDiv.dataset.loaded) { detailDiv.dataset.loaded = 'true'; loadFeedDetail(feedUrl, detailDiv); } } }); } // Hover effect row.addEventListener('mouseenter', () => row.style.background = '#1a1a1a'); row.addEventListener('mouseleave', () => row.style.background = 'transparent'); }); } // Show all domains view // statusFilter can be: 'hold', 'pass', 'skip', 'fail', or 'feeds' (special: has_feeds=true) async function showDomains(statusFilter = null) { currentView = 'domains'; currentDomain = null; clearInfiniteScroll(); const output = document.getElementById('output'); const breadcrumb = document.getElementById('breadcrumb'); // Breadcrumb let bc = 'domains'; if (statusFilter) { bc += ` / ${statusFilter}`; } breadcrumb.innerHTML = bc; breadcrumb.style.display = 'block'; // Add breadcrumb handler breadcrumb.querySelector('.bc-link').addEventListener('click', () => showDomains()); output.innerHTML = '
Loading...
'; let offset = 0; const limit = 100; let currentTLD = null; async function loadMore() { try { let url = `/api/domains?limit=${limit}&offset=${offset}&sort=alpha`; if (statusFilter === 'feeds') { url += `&has_feeds=true`; } else if (statusFilter) { url += `&status=${statusFilter}`; } const resp = await fetch(url); const domains = await resp.json(); if (!domains || domains.length === 0) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No domains found' : 'End of list'; return; } const container = output.querySelector('.domain-list'); domains.forEach(d => { // Insert TLD header if TLD changed if (d.tld && d.tld !== currentTLD) { currentTLD = d.tld; container.insertAdjacentHTML('beforeend', `
.${escapeHtml(currentTLD)}
`); } container.insertAdjacentHTML('beforeend', renderDomainRow(d)); }); attachDomainHandlers(container); applyTLDFilter(); // Auto-expand feeds in d:feeds mode if (statusFilter === 'feeds') { // Show the domain-feeds containers container.querySelectorAll('.domain-feeds').forEach(feedsDiv => { feedsDiv.style.display = 'block'; }); // Also expand feed details container.querySelectorAll('.inline-feed-block').forEach(feedBlock => { const detailDiv = feedBlock.querySelector('.feed-detail'); const feedUrl = feedBlock.dataset.url; if (detailDiv && detailDiv.style.display === 'none') { detailDiv.style.display = 'block'; if (!detailDiv.dataset.loaded) { detailDiv.dataset.loaded = 'true'; loadFeedDetail(feedUrl, detailDiv); } } }); } offset += domains.length; if (domains.length < limit) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = 'End of list'; } } catch (err) { document.getElementById('infiniteLoader').textContent = 'Error: ' + err.message; } } await loadMore(); setupInfiniteScroll(loadMore); } // Show feeds for a specific domain async function showDomainFeeds(host) { currentView = 'domain-feeds'; currentDomain = host; clearInfiniteScroll(); const output = document.getElementById('output'); const breadcrumb = document.getElementById('breadcrumb'); // Breadcrumb breadcrumb.innerHTML = ` domains / ${escapeHtml(host)} `; breadcrumb.style.display = 'block'; breadcrumb.querySelector('.bc-link').addEventListener('click', () => showDomains()); output.innerHTML = '
Loading...
'; let offset = 0; const limit = 100; async function loadMore() { try { const resp = await fetch(`/api/domainFeeds?host=${encodeURIComponent(host)}&limit=${limit}&offset=${offset}`); const feeds = await resp.json(); if (!feeds || feeds.length === 0) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No feeds found' : 'End of list'; return; } const container = output.querySelector('.feed-list'); feeds.forEach(f => container.insertAdjacentHTML('beforeend', renderFeedRow(f))); attachFeedHandlers(container); offset += feeds.length; if (feeds.length < limit) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = 'End of list'; } } catch (err) { document.getElementById('infiniteLoader').textContent = 'Error: ' + err.message; } } await loadMore(); setupInfiniteScroll(loadMore); } // Show all feeds view (shows domains with feeds, or flat feed list if filtered) async function showFeeds(statusFilter = null) { currentView = 'feeds'; currentDomain = null; clearInfiniteScroll(); const output = document.getElementById('output'); const breadcrumb = document.getElementById('breadcrumb'); // Breadcrumb let bc = 'feeds'; if (statusFilter) { bc += ` / ${statusFilter}`; } breadcrumb.innerHTML = bc; breadcrumb.style.display = 'block'; breadcrumb.querySelector('.bc-link').addEventListener('click', () => showFeeds()); // When no filter, show domains with feeds; when filtered, show flat feed list if (!statusFilter) { output.innerHTML = '
Loading...
'; let offset = 0; const limit = 100; let currentTLD = null; async function loadMore() { try { const url = `/api/domains?has_feeds=true&limit=${limit}&offset=${offset}&sort=alpha`; const resp = await fetch(url); const domains = await resp.json(); if (!domains || domains.length === 0) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No domains with feeds found' : 'End of list'; return; } const container = output.querySelector('.domain-list'); domains.forEach(d => { // Insert TLD header if TLD changed if (d.tld && d.tld !== currentTLD) { currentTLD = d.tld; container.insertAdjacentHTML('beforeend', `
.${escapeHtml(currentTLD)}
`); } container.insertAdjacentHTML('beforeend', renderDomainRow(d)); }); attachDomainHandlers(container); applyTLDFilter(); offset += domains.length; if (domains.length < limit) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = 'End of list'; } } catch (err) { document.getElementById('infiniteLoader').textContent = 'Error: ' + err.message; } } await loadMore(); setupInfiniteScroll(loadMore); } else { // Filtered view: show flat feed list output.innerHTML = '
Loading...
'; let offset = 0; const limit = 100; async function loadMore() { try { let url = `/api/feeds?limit=${limit}&offset=${offset}&sort=alpha`; url += `&publish_status=${statusFilter}`; const resp = await fetch(url); const feeds = await resp.json(); if (!feeds || feeds.length === 0) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No feeds found' : 'End of list'; return; } const container = output.querySelector('.feed-list'); feeds.forEach(f => container.insertAdjacentHTML('beforeend', renderFeedRow(f))); attachFeedHandlers(container); offset += feeds.length; if (feeds.length < limit) { if (infiniteScrollState) infiniteScrollState.ended = true; document.getElementById('infiniteLoader').textContent = 'End of list'; } } catch (err) { document.getElementById('infiniteLoader').textContent = 'Error: ' + err.message; } } await loadMore(); setupInfiniteScroll(loadMore); } } // Show feed info async function showFeedInfo(feedUrl) { clearInfiniteScroll(); const output = document.getElementById('output'); const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ` feeds / ${escapeHtml(feedUrl)}`; breadcrumb.style.display = 'block'; breadcrumb.querySelector('.bc-link').addEventListener('click', () => { if (currentDomain) showDomainFeeds(currentDomain); else showFeeds(); }); output.innerHTML = '
Loading feed info...
'; try { const [infoResp, itemsResp] = await Promise.all([ fetch('/api/feedInfo?url=' + encodeURIComponent(feedUrl)), fetch('/api/feedItems?url=' + encodeURIComponent(feedUrl) + '&limit=50') ]); const info = await infoResp.json(); const items = await itemsResp.json(); let html = '
'; // Title and status buttons html += '
'; html += renderStatusBtns(info.publishStatus || 'hold', 'feed', feedUrl, info.status); html += `${escapeHtml(info.title || feedUrl)}`; html += '
'; // URL html += `
${escapeHtml(feedUrl)}
`; // Description if (info.description) { html += `
${escapeHtml(info.description)}
`; } // Info table html += ''; const addRow = (label, value, color) => { if (!value) return ''; return ``; }; html += addRow('Type', info.type); html += addRow('Status', info.status, info.status === 'active' ? '#0a0' : '#f66'); html += addRow('Language', info.language); html += addRow('Items', info.itemCount ? commaFormat(info.itemCount) : null); html += addRow('Source', info.sourceHost); if (info.publishAccount) { html += addRow('Account', info.publishAccount, '#0af'); } html += '
${label}${escapeHtml(String(value))}
'; // Items if (items && items.length > 0) { html += '
'; html += `
Recent Items (${items.length})
`; items.forEach(item => { html += '
'; if (item.title && item.link) { html += `${escapeHtml(item.title)}`; } else if (item.title) { html += `${escapeHtml(item.title)}`; } if (item.pub_date) { const date = new Date(item.pub_date); html += `${date.toLocaleDateString()}`; } html += '
'; }); html += '
'; } html += '
'; output.innerHTML = html; // Attach status handler attachStatusHandlers(output); } catch (err) { output.innerHTML = `
Error: ${escapeHtml(err.message)}
`; } } // Search async function performSearch(query) { clearInfiniteScroll(); const output = document.getElementById('output'); const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ` domains / search: ${escapeHtml(query)}`; breadcrumb.style.display = 'block'; breadcrumb.querySelector('.bc-link').addEventListener('click', () => showDomains()); output.innerHTML = '
Searching...
'; try { const resp = await fetch('/api/search?q=' + encodeURIComponent(query) + '&limit=200'); const results = await resp.json(); if (!results || results.length === 0) { output.innerHTML = '
No results found
'; return; } let html = `
${results.length} result(s)
`; html += '
'; results.forEach(r => { const f = r.feed; html += renderFeedRow(f); }); html += '
'; output.innerHTML = html; attachFeedHandlers(output.querySelector('.feed-list')); } catch (err) { output.innerHTML = `
Search error: ${escapeHtml(err.message)}
`; } } // Process command function processCommand(cmd) { const trimmed = cmd.trim().toLowerCase(); if (!trimmed || trimmed === '/help') { showHelp(); return; } if (trimmed === '/domains' || trimmed === 'domains') { showDomains(); return; } if (trimmed === '/feeds' || trimmed === 'feeds') { showFeeds(); return; } // Domain status filters (domains:hold or d:hold) if (trimmed.startsWith('domains:')) { const status = trimmed.substring(8); showDomains(status); return; } if (trimmed.startsWith('d:')) { const status = trimmed.substring(2); showDomains(status); return; } // Feed status filters (feeds:hold or f:hold) if (trimmed.startsWith('feeds:')) { const status = trimmed.substring(6); showFeeds(status); return; } if (trimmed.startsWith('f:')) { const status = trimmed.substring(2); showFeeds(status); return; } // Otherwise, treat as search performSearch(cmd.trim()); } // Show help function showHelp() { clearInfiniteScroll(); const output = document.getElementById('output'); const breadcrumb = document.getElementById('breadcrumb'); breadcrumb.innerHTML = ''; breadcrumb.style.display = 'none'; output.innerHTML = `
Commands
/domains - List all domains
/feeds - List all feeds
d:hold d:pass d:skip d:fail - Filter domains by status
d:feeds - Filter domains with discovered feeds
f:hold f:pass f:skip f:fail - Filter feeds by status
[text] - Search feeds
Click domain/feed name to drill down. Click status buttons to set status.
`; } // Setup command input function setupCommandInput() { const input = document.getElementById('commandInput'); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') processCommand(input.value); }); input.addEventListener('focus', () => input.select()); // Command buttons document.querySelectorAll('.cmd-btn[data-cmd]').forEach(btn => { btn.addEventListener('click', () => { const cmd = btn.dataset.cmd; input.value = cmd; processCommand(cmd); }); }); } // Setup TLD filter async function setupTLDFilter() { const tldToggleBtn = document.getElementById('tldToggleBtn'); const tldDropdown = document.getElementById('tldDropdown'); const tldList = document.getElementById('tldList'); // Toggle dropdown tldToggleBtn.addEventListener('click', async () => { const isVisible = tldDropdown.style.display !== 'none'; if (isVisible) { tldDropdown.style.display = 'none'; } else { tldDropdown.style.display = 'block'; if (allTLDs.length === 0) { await loadTLDs(); } } }); } async function loadTLDs() { const tldList = document.getElementById('tldList'); tldList.innerHTML = 'Loading...'; try { const resp = await fetch('/api/tlds'); allTLDs = await resp.json(); renderTLDButtons(); } catch (err) { tldList.innerHTML = `Error: ${escapeHtml(err.message)}`; } } function renderTLDButtons() { const tldList = document.getElementById('tldList'); const allSelected = selectedTLDs.size === 0 || selectedTLDs.size === allTLDs.length; let html = ''; allTLDs.forEach(t => { const isSelected = selectedTLDs.has(t.tld); const bg = isSelected ? '#036' : '#1a1a1a'; const border = isSelected ? '#06c' : '#333'; const color = isSelected ? '#0af' : '#fff'; html += ``; }); // Add clear button if any selected if (selectedTLDs.size > 0) { html += ``; } tldList.innerHTML = html; // Attach click handlers tldList.querySelectorAll('.tld-btn').forEach(btn => { btn.addEventListener('click', () => { const tld = btn.dataset.tld; if (selectedTLDs.has(tld)) { selectedTLDs.delete(tld); } else { selectedTLDs.add(tld); } renderTLDButtons(); applyTLDFilter(); }); }); const clearBtn = document.getElementById('clearTLDs'); if (clearBtn) { clearBtn.addEventListener('click', () => { selectedTLDs.clear(); renderTLDButtons(); applyTLDFilter(); }); } } // Track manually collapsed TLDs (collapsed by clicking header) let collapsedTLDs = new Set(); function applyTLDFilter() { const container = document.querySelector('.domain-list'); if (!container) return; const showAll = selectedTLDs.size === 0; // Handle TLD headers and their domain blocks container.querySelectorAll('.tld-header:not(.tld-handled)').forEach(header => { header.classList.add('tld-handled'); header.style.cursor = 'pointer'; header.addEventListener('click', () => { const tldText = header.dataset.tld; // If TLD filter is active, clicking adds/removes from filter if (selectedTLDs.size > 0) { if (selectedTLDs.has(tldText)) { selectedTLDs.delete(tldText); } else { selectedTLDs.add(tldText); } renderTLDButtons(); } else { // No filter active - toggle manual collapse if (collapsedTLDs.has(tldText)) { collapsedTLDs.delete(tldText); } else { collapsedTLDs.add(tldText); } } applyTLDVisibility(); }); }); // Store TLD in data attribute for headers that don't have it container.querySelectorAll('.tld-header').forEach(header => { if (!header.dataset.tld) { const match = header.textContent.trim().match(/^\.(.+?)(?:\s|$)/); if (match) { header.dataset.tld = match[1]; } } }); applyTLDVisibility(); } function applyTLDVisibility() { const container = document.querySelector('.domain-list'); if (!container) return; const showAll = selectedTLDs.size === 0; container.querySelectorAll('.tld-header').forEach(header => { const tldText = header.dataset.tld; if (!tldText) return; // Determine if this TLD should be expanded let isExpanded; if (selectedTLDs.size > 0) { // Filter mode: show only selected TLDs isExpanded = selectedTLDs.has(tldText); } else { // No filter: show all except manually collapsed isExpanded = !collapsedTLDs.has(tldText); } // Find all domain blocks until next TLD header, track last domain block let nextEl = header.nextElementSibling; let lastDomainBlock = null; while (nextEl && !nextEl.classList.contains('tld-header')) { if (nextEl.classList.contains('domain-block')) { nextEl.style.display = isExpanded ? 'block' : 'none'; lastDomainBlock = nextEl; } if (nextEl.classList.contains('tld-footer')) { nextEl.style.display = isExpanded ? 'block' : 'none'; } nextEl = nextEl.nextElementSibling; } // Insert footer if it doesn't exist and we have domain blocks if (lastDomainBlock) { let footer = lastDomainBlock.nextElementSibling; if (!footer || !footer.classList.contains('tld-footer')) { const footerHtml = ``; lastDomainBlock.insertAdjacentHTML('afterend', footerHtml); // Add click handler to new footer footer = lastDomainBlock.nextElementSibling; footer.addEventListener('click', () => { collapsedTLDs.add(tldText); applyTLDVisibility(); }); } } // Style header based on expanded state header.style.color = isExpanded ? '#888' : '#555'; // Add expand/collapse indicator const indicator = isExpanded ? '▼' : '▶'; const tldDisplay = '.' + tldText; if (!header.textContent.includes('▼') && !header.textContent.includes('▶')) { header.innerHTML = `${indicator}${tldDisplay}`; } else { header.innerHTML = header.innerHTML.replace(/[▼▶]/, indicator); } }); } // Stats update async function updateStats() { try { const resp = await fetch('/api/stats'); const stats = await resp.json(); document.getElementById('totalDomains').textContent = commaFormat(stats.total_domains); document.getElementById('holdDomains').textContent = commaFormat(stats.hold_domains); document.getElementById('passDomains').textContent = commaFormat(stats.pass_domains); document.getElementById('skipDomains').textContent = commaFormat(stats.skip_domains); document.getElementById('failDomains').textContent = commaFormat(stats.fail_domains); document.getElementById('crawlRate').textContent = commaFormat(stats.crawl_rate); document.getElementById('checkRate').textContent = commaFormat(stats.check_rate); document.getElementById('totalFeeds').textContent = commaFormat(stats.total_feeds); document.getElementById('rssFeeds').textContent = commaFormat(stats.rss_feeds); document.getElementById('atomFeeds').textContent = commaFormat(stats.atom_feeds); document.getElementById('unknownFeeds').textContent = commaFormat(stats.unknown_feeds); const updatedAt = new Date(stats.updated_at); document.getElementById('updatedAt').textContent = 'Last updated: ' + updatedAt.toISOString().replace('T', ' ').substring(0, 19); } catch (err) { console.error('Failed to update stats:', err); } } // Initialize setupCommandInput(); setupTLDFilter(); showHelp(); updateStats(); setInterval(updateStats, 1000); } window.onload = initDashboard;