diff --git a/api_domains.go b/api_domains.go
index 474df22..6ba1ee7 100644
--- a/api_domains.go
+++ b/api_domains.go
@@ -49,6 +49,7 @@ func (c *Crawler) handleAPIAllDomains(w http.ResponseWriter, r *http.Request) {
func (c *Crawler) handleAPIDomains(w http.ResponseWriter, r *http.Request) {
status := r.URL.Query().Get("status")
hasFeeds := r.URL.Query().Get("has_feeds") == "true"
+ search := r.URL.Query().Get("search")
limit := 100
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
@@ -66,7 +67,25 @@ func (c *Crawler) handleAPIDomains(w http.ResponseWriter, r *http.Request) {
var err error
if hasFeeds {
// Only domains with feeds
- if status != "" {
+ searchPattern := "%" + strings.ToLower(search) + "%"
+ if search != "" {
+ // Search in domain host or feed title/url
+ rows, err = c.db.Query(`
+ SELECT DISTINCT d.host, d.tld, d.status, d.last_error, f.feed_count
+ FROM domains d
+ INNER JOIN (
+ SELECT source_host, COUNT(*) as feed_count
+ FROM feeds
+ WHERE item_count > 0
+ GROUP BY source_host
+ ) f ON d.host = f.source_host
+ LEFT JOIN feeds fe ON d.host = fe.source_host
+ WHERE d.status != 'skip'
+ AND (LOWER(d.host) LIKE $1 OR LOWER(fe.title) LIKE $1 OR LOWER(fe.url) LIKE $1)
+ ORDER BY d.tld ASC, d.host ASC
+ LIMIT $2 OFFSET $3
+ `, searchPattern, limit, offset)
+ } else if status != "" {
rows, err = c.db.Query(`
SELECT d.host, d.tld, d.status, d.last_error, f.feed_count
FROM domains d
diff --git a/static/dashboard.js b/static/dashboard.js
index 2ec0fac..730b99d 100644
--- a/static/dashboard.js
+++ b/static/dashboard.js
@@ -11,12 +11,9 @@ function initDashboard() {
}
// 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
+ let searchQuery = '';
// Event delegation for domain-spacer clicks (toggle feeds)
document.addEventListener('click', (e) => {
@@ -28,7 +25,6 @@ function initDashboard() {
if (feedsDiv) {
const isVisible = feedsDiv.style.display !== 'none';
feedsDiv.style.display = isVisible ? 'none' : 'block';
- // Load items for all feeds when opening
if (!isVisible) {
feedsDiv.querySelectorAll('.inline-feed-block').forEach(feedBlock => {
const itemsDiv = feedBlock.querySelector('.feed-items');
@@ -78,9 +74,9 @@ function initDashboard() {
}
});
- // Load feed info into info div
+ // Load feed info
async function loadFeedInfo(feedUrl, infoDiv) {
- infoDiv.innerHTML = 'Loading feed info... ';
+ infoDiv.innerHTML = 'Loading... ';
try {
const resp = await fetch(`/api/feedInfo?url=${encodeURIComponent(feedUrl)}`);
if (!resp.ok) throw new Error('Failed to load');
@@ -93,7 +89,6 @@ function initDashboard() {
['Description', f.description],
['Type', f.type],
['Language', f.language],
- ['Category', f.category],
['Site URL', f.siteUrl],
['Status', f.status],
['Error Count', f.errorCount],
@@ -121,9 +116,9 @@ function initDashboard() {
}
}
- // Load feed items into items div
+ // Load feed items
async function loadFeedItems(feedUrl, itemsDiv) {
- itemsDiv.innerHTML = 'Loading items... ';
+ itemsDiv.innerHTML = 'Loading... ';
try {
const resp = await fetch(`/api/feedItems?url=${encodeURIComponent(feedUrl)}&limit=50`);
if (!resp.ok) throw new Error('Failed to load');
@@ -152,48 +147,135 @@ function initDashboard() {
}
}
- // Status colors and labels
+ // Status colors
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' }
+ hold: { color: '#f90', bg: '#330', border: '#550' },
+ skip: { color: '#f66', bg: '#400', border: '#600' },
+ pass: { color: '#0f0', bg: '#040', border: '#060' },
+ fail: { color: '#f00', bg: '#400', border: '#600' }
};
- // Render status button group
- // For domains: pass, hold, skip (+ fail indicator or spacer)
- // For feeds: pass, hold, skip, drop (+ fail indicator or spacer)
+ // Render status buttons
function renderStatusBtns(currentStatus, type, id, errorStatus) {
- const order = type === 'feed' ? ['pass', 'hold', 'skip', 'drop'] : ['pass', 'hold', 'skip'];
+ const order = ['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 isActive = s === currentStatus;
+ const bg = isActive ? cfg.bg : '#111';
const border = isActive ? cfg.border : '#333';
+ const color = isActive ? cfg.color : '#444';
html += `${cfg.label} `;
+ color: ${color}; cursor: pointer; margin-left: ${i > 0 ? '1px' : '0'};">${s}`;
});
- // Fail indicator (not clickable) or hidden spacer for alignment
- const cfg = statusConfig.fail;
- const failBtnStyle = `padding: 2px 6px; font-family: monospace;
- background: ${cfg.bg}; border: 1px solid ${cfg.border}; border-radius: 3px;
- color: ${cfg.color}; cursor: default; margin-left: 1px; box-sizing: border-box;`;
if (showFail) {
- html += `${cfg.label} `;
- } else {
- // Hidden spacer - same style but invisible
- html += `${cfg.label} `;
+ const cfg = statusConfig.fail;
+ html += `fail `;
}
html += '
';
return html;
}
+ // Render domain row with feeds
+ function renderDomainRow(d) {
+ const status = d.status || 'hold';
+ const hasError = !!d.last_error;
+
+ let html = ``;
+ html += `
`;
+ html += renderStatusBtns(status, 'domain', d.host, hasError ? 'error' : null);
+ html += `
${escapeHtml(d.host)} `;
+
+ if (d.last_error) {
+ html += `
${escapeHtml(d.last_error)} `;
+ } else {
+ html += '
';
+ }
+ html += '
';
+
+ // Feeds (shown by default in this view)
+ if (d.feeds && d.feeds.length > 0) {
+ html += '
';
+ d.feeds.forEach(f => {
+ const feedStatus = f.publish_status || 'hold';
+ html += `
`;
+ html += `
`;
+
+ const lang = f.language || '';
+ html += `${escapeHtml(lang)} `;
+ html += renderStatusBtns(feedStatus, 'feed', f.url, f.status);
+
+ const statusColor = f.status === 'active' ? '#484' : f.status === 'error' ? '#a66' : '#666';
+ html += `${escapeHtml(f.status || 'active')} `;
+
+ if (f.item_count > 0) {
+ html += `${commaFormat(f.item_count)} `;
+ } else {
+ html += ` `;
+ }
+
+ 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) {}
+ html += `${escapeHtml(feedPath)} `;
+
+ if (f.title) {
+ html += `${escapeHtml(f.title)} `;
+ }
+ html += ' ';
+ html += '
';
+ html += '
';
+ html += '
';
+ html += '
';
+ });
+ html += '
';
+ }
+ html += '
';
+ return html;
+ }
+
+ // Attach status button handlers
+ function attachStatusHandlers(container) {
+ container.querySelectorAll('.status-btn:not(.btn-handled)').forEach(btn => {
+ btn.classList.add('btn-handled');
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const type = btn.dataset.type;
+ const id = btn.dataset.id;
+ const newStatus = btn.dataset.status;
+
+ const endpoint = type === 'domain' ? '/api/setDomainStatus' : '/api/setPublishStatus';
+ const param = type === 'domain' ? 'host' : 'url';
+
+ try {
+ const resp = await fetch(`${endpoint}?${param}=${encodeURIComponent(id)}&status=${newStatus}`);
+ if (resp.ok) {
+ const group = btn.closest('.status-btn-group');
+ group.querySelectorAll('.status-btn').forEach(b => {
+ const s = b.dataset.status;
+ const cfg = statusConfig[s];
+ const isActive = s === newStatus;
+ b.style.background = isActive ? cfg.bg : '#111';
+ b.style.borderColor = isActive ? cfg.border : '#333';
+ b.style.color = isActive ? cfg.color : '#444';
+ });
+ const block = btn.closest(type === 'domain' ? '.domain-block' : '.inline-feed-block');
+ if (block) block.dataset.status = newStatus;
+ }
+ } catch (err) {
+ console.error('Status update failed:', err);
+ }
+ });
+ });
+ }
+
// Infinite scroll
function setupInfiniteScroll(loadMoreFn) {
infiniteScrollState = { loadMore: loadMoreFn, ended: false };
@@ -203,418 +285,30 @@ function initDashboard() {
infiniteScrollState = null;
}
- function checkInfiniteScroll() {
+ window.addEventListener('scroll', async () => {
if (!infiniteScrollState || infiniteScrollState.ended || isLoadingMore) return;
- const scrollBottom = window.scrollY + window.innerHeight;
+ const scrollY = window.scrollY + window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
- if (docHeight - scrollBottom < 300) {
+ if (scrollY > docHeight - 500) {
isLoadingMore = true;
- infiniteScrollState.loadMore().finally(() => {
- isLoadingMore = false;
- });
+ await infiniteScrollState.loadMore();
+ 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 (links to site)
- html += `
${escapeHtml(d.host)} `;
-
- // Spacer (clickable to toggle feeds) - uses to ensure clickable area
- if (d.last_error) {
- html += `
${escapeHtml(d.last_error)} `;
- } else {
- html += '
';
- }
-
- // Drop button (only for skipped domains)
- if (status === 'skip') {
- html += `
drop `;
- }
-
- html += '
';
-
- // Feeds under this domain (hidden by default, toggled by clicking spacer)
- if (d.feeds && d.feeds.length > 0) {
- html += '
';
- d.feeds.forEach(f => {
- const feedStatus = f.publish_status || 'hold';
-
- html += `
`;
- html += `
`;
-
- // Language indicator (fixed width)
- const lang = f.language || '';
- html += `${escapeHtml(lang)} `;
-
- // Feed publish status buttons (pass/hold/skip)
- html += renderStatusBtns(feedStatus, 'feed', f.url, f.status);
-
- // Feed crawl status (active/error/dead)
- const statusColor = f.status === 'active' ? '#484' : f.status === 'error' ? '#a66' : '#666';
- html += `${escapeHtml(f.status || 'active')} `;
-
- // Item count if > 0
- if (f.item_count > 0) {
- html += `${commaFormat(f.item_count)} `;
- } else {
- html += ` `;
- }
-
- // Feed path (URL without domain) - click to toggle feed info
- 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) {}
- html += `${escapeHtml(feedPath)} `;
-
- // Feed title - click to toggle items
- if (f.title) {
- html += `${escapeHtml(f.title)} `;
- }
- // Filler span - also toggles items
- html += ' ';
-
- html += '
';
-
- // Feed info section (hidden by default)
- html += '
';
-
- // Feed items section (shown by default)
- 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;
-
- // 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();
+ });
+ // Load and display feeds
+ async function loadFeeds(query = '') {
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}`;
+ let url = `/api/domains?limit=${limit}&offset=${offset}&sort=alpha&has_feeds=true`;
+ if (query) {
+ url += `&search=${encodeURIComponent(query)}`;
}
const resp = await fetch(url);
@@ -622,38 +316,24 @@ function initDashboard() {
if (!domains || domains.length === 0) {
if (infiniteScrollState) infiniteScrollState.ended = true;
- document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No domains found' : 'End of list';
+ document.getElementById('infiniteLoader').textContent = offset === 0 ? 'No 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();
+ attachStatusHandlers(container);
- // Auto-expand feeds in d:feeds mode
- if (statusFilter === 'feeds') {
- // Show the domain-feeds containers and load items
- container.querySelectorAll('.domain-feeds').forEach(feedsDiv => {
- feedsDiv.style.display = 'block';
- });
- // Load items for all feeds
- container.querySelectorAll('.inline-feed-block').forEach(feedBlock => {
- const itemsDiv = feedBlock.querySelector('.feed-items');
- const feedUrl = feedBlock.dataset.url;
- if (itemsDiv && !itemsDiv.dataset.loaded) {
- itemsDiv.dataset.loaded = 'true';
- loadFeedItems(feedUrl, itemsDiv);
- }
- });
- }
+ // Load items for all feeds
+ container.querySelectorAll('.inline-feed-block').forEach(feedBlock => {
+ const itemsDiv = feedBlock.querySelector('.feed-items');
+ if (itemsDiv && !itemsDiv.dataset.loaded) {
+ itemsDiv.dataset.loaded = 'true';
+ loadFeedItems(feedBlock.dataset.url, itemsDiv);
+ }
+ });
offset += domains.length;
@@ -670,592 +350,26 @@ function initDashboard() {
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
+ // Search handler
+ const searchInput = document.getElementById('searchInput');
+ let searchTimeout;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ searchQuery = searchInput.value.trim();
+ clearInfiniteScroll();
+ loadFeeds(searchQuery);
+ }, 300);
+ });
+
+ // Initial load
+ loadFeeds();
+
+ // Update stats periodically
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);
@@ -1263,25 +377,17 @@ function initDashboard() {
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);
+ document.getElementById('updatedAt').textContent = 'Last updated: ' + new Date().toLocaleString();
} catch (err) {
- console.error('Failed to update stats:', err);
+ console.error('Stats update failed:', err);
}
}
- // Initialize
- setupCommandInput();
- setupTLDFilter();
- showHelp();
- updateStats();
- setInterval(updateStats, 1000);
+ setInterval(updateStats, 60000);
}
-window.onload = initDashboard;
+document.addEventListener('DOMContentLoaded', initDashboard);
diff --git a/templates.go b/templates.go
index 356d3f6..b5023b4 100644
--- a/templates.go
+++ b/templates.go
@@ -503,38 +503,15 @@ const dashboardHTML = `
- v58
+ v59
Last updated: {{.UpdatedAt.Format "2006-01-02 15:04:05"}}