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 += `${cfg.label} `;
});
// 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 += `${cfg.label} `;
} else {
// Hidden spacer - same style but invisible
html += `${cfg.label} `;
}
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 += `
drop `;
}
html += '
';
// Feeds under this domain (hidden by default, toggled by clicking domain name)
if (d.feeds && d.feeds.length > 0) {
html += '
';
d.feeds.forEach(f => {
const feedStatus = f.publish_status || 'hold';
const feedHasError = f.status === 'error' || f.status === 'dead';
html += `
`;
html += `
`;
// Language indicator (fixed width)
const lang = f.language || '';
html += `${escapeHtml(lang)} `;
// Feed status buttons
html += renderStatusBtns(feedStatus, 'feed', f.url, f.status);
// Feed path + title
let feedPath = f.url;
try {
const urlObj = new URL(f.url.startsWith('http') ? f.url : 'https://' + f.url);
feedPath = urlObj.pathname + urlObj.search;
} catch (e) {}
const displayText = f.title ? `${feedPath} - ${f.title}` : feedPath;
html += `${escapeHtml(displayText)} `;
// Feed type
if (f.type) {
html += `${escapeHtml(f.type)} `;
}
html += '
';
// Collapsible detail section
html += '
';
html += '
';
});
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', ``);
}
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', ``);
}
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 `${label} ${escapeHtml(String(value))} `;
};
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 += '
';
// 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 += `.${escapeHtml(t.tld)} (${t.domain_count}) `;
});
// Add clear button if any selected
if (selectedTLDs.size > 0) {
html += `clear `;
}
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;