From f49fc2f0ad71973186526102dd309a6773eeb029 Mon Sep 17 00:00:00 2001 From: primal Date: Fri, 30 Jan 2026 17:16:14 -0500 Subject: [PATCH] v59: simplify to single feeds view with search --- api_domains.go | 21 +- static/dashboard.js | 1206 ++++++------------------------------------- templates.go | 29 +- 3 files changed, 179 insertions(+), 1077 deletions(-) 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 += ``; + 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 += ``; - } else { - // Hidden spacer - same style but invisible - html += ``; + const cfg = statusConfig.fail; + html += ``; } 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 += ``; - } - - html += '
'; - - // Feeds under this domain (hidden by default, toggled by clicking spacer) - 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; - - // 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', `
.${escapeHtml(currentTLD)}
`); - } 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', `
.${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 + // 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"}}