From 8a127132213c520bb6d02af32d3a37a60ab4fe26 Mon Sep 17 00:00:00 2001 From: primal Date: Mon, 2 Feb 2026 12:45:08 -0500 Subject: [PATCH] Add static files - CSS and JS Copied from app/static/: - dashboard.css (4.7KB) - dashboard.js (41KB) Co-Authored-By: Claude Opus 4.5 --- static/dashboard.css | 185 +++++++++ static/dashboard.js | 874 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1059 insertions(+) create mode 100644 static/dashboard.css create mode 100644 static/dashboard.js diff --git a/static/dashboard.css b/static/dashboard.css new file mode 100644 index 0000000..7407cbb --- /dev/null +++ b/static/dashboard.css @@ -0,0 +1,185 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; + background: #0a0a0a; + color: #ffffff; + padding: 0 10px; + line-height: 1.6; +} +h1 { color: #ffffff; margin-bottom: 20px; font-size: 24px; } +h2 { color: #ffffff; margin: 4px 0; font-size: 14px; text-transform: uppercase; letter-spacing: 1px; } +h2:first-child { margin-top: 0; } +#topSection { + background: #0a0a0a; + padding: 0 0 4px 0; +} +#topSection.fixed { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + padding: 0 10px 4px 10px; + transform: translateY(0); + transition: transform 0.3s ease; +} +#topSection.fixed.hidden { + transform: translateY(-100%); +} +#topSectionSpacer { + display: none; +} +#topSectionSpacer.active { + display: block; +} +#inputCard { margin: 10px 0; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 20px; } +.grid-narrow { display: inline-grid; grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 115px; gap: 10px; margin-bottom: 0; } +.card { + background: #151515; + border: 1px solid #252525; + border-radius: 8px; + padding: 15px; +} +.card.clickable { + cursor: pointer; + transition: background 0.2s, border-color 0.2s, transform 0.1s; +} +.card.clickable:hover { + background: #1a1a1a; + border-color: #0af; +} +.card.clickable:active { + transform: scale(0.98); +} +.card.clickable.active { + background: #1a1a1a; + border-color: #0af; + box-shadow: 0 0 10px rgba(0, 170, 255, 0.3); +} +.stat-value { font-weight: bold; color: #ffffff; text-align: center; } +.stat-label { color: #888; text-transform: uppercase; text-align: center; } +.stat-row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #202020; color: #ffffff; } +.stat-row:last-child { border-bottom: none; } +.progress-bar { + background: #202020; + border-radius: 4px; + height: 8px; + margin-top: 10px; + overflow: hidden; +} +.progress-fill { + background: linear-gradient(90deg, #00aa55, #00cc66); + height: 100%; + transition: width 0.3s; +} +table { width: 100%; border-collapse: collapse; color: #ffffff; } +th, td { text-align: left; padding: 8px; border-bottom: 1px solid #202020; } +th { color: #ffffff; font-size: 11px; text-transform: uppercase; } +td { font-size: 13px; color: #ffffff; } +.type-rss { color: #f90; } +.type-atom { color: #09f; } +.type-unknown { color: #ffffff; } +.url { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #4a9eff; +} +.time { color: #ffffff; font-size: 12px; } +.updated { color: #ffffff; font-size: 11px; text-align: right; margin-top: 20px; } + +/* Search */ +#searchInput:focus { outline: none; border-color: #0af; } +#searchInput::placeholder { color: #555; } +.search-host { margin-bottom: 10px; } +.search-feed:hover { background: #1a1a1a; } + +/* Command buttons */ +.cmd-btn { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 4px; + color: #0af; + padding: 6px 12px; + margin-right: 8px; + margin-bottom: 4px; + font-size: 13px; + font-family: monospace; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.cmd-btn:hover { + background: #252525; + border-color: #0af; +} +.cmd-btn:active { + background: #0af; + color: #000; +} + +/* Visit link */ +.visit-link:hover { + color: #0af !important; +} + +/* TLD Grid */ +.domain-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.tld-section { + width: 135px; + background: #151515; + border: 1px solid #252525; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} +.tld-section:hover { + background: #1a1a1a; + border-color: #0af; +} +.tld-section.expanded { + background: #1a1a1a; + border-color: #0af; + box-shadow: 0 0 10px rgba(0, 170, 255, 0.3); +} +.tld-section .tld-header { + padding: 4px; + text-align: center; +} +.tld-section .tld-name { + color: #0af; + font-weight: normal; + font-size: 10pt; +} +.tld-section .tld-content { + display: none; +} +/* Expanded content shown in separate container */ +#expandedTLDContent { + margin-top: 10px; + background: #151515; + border: 1px solid #0af; + border-radius: 8px; +} +#expandedTLDContent .tld-header { + display: flex; + align-items: center; + padding: 10px; + background: #1a1a1a; + border-bottom: 1px solid #333; + cursor: pointer; +} +#expandedTLDContent .tld-toggle { + color: #666; + margin-right: 10px; +} +#expandedTLDContent .tld-name { + color: #0af; + font-weight: bold; + font-size: 1.1em; +} diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..9d58a11 --- /dev/null +++ b/static/dashboard.js @@ -0,0 +1,874 @@ +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 infiniteScrollState = null; + let isLoadingMore = false; + let searchQuery = ''; + let domainFilter = 'all'; // all, pass, skip, hold, dead + // Feed filter: multi-select with ALL as exclusion toggle + // When allSelected=true, selected items are EXCLUDED; when false, selected items are INCLUDED + let feedFilter = { allSelected: false, statuses: [], types: [] }; + let currentOpenTLD = null; // Track which TLD is currently open + + // Smart sticky header - scroll normally, show fixed on scroll up + let lastScrollY = 0; + const topSection = document.getElementById('topSection'); + const spacer = document.getElementById('topSectionSpacer'); + let headerHeight = topSection.offsetHeight; + let isFixed = false; + + window.addEventListener('scroll', () => { + const currentScrollY = window.scrollY; + + // If at top, return to normal flow + if (currentScrollY <= 0) { + topSection.classList.remove('fixed', 'hidden'); + spacer.classList.remove('active'); + isFixed = false; + lastScrollY = currentScrollY; + return; + } + + // Only activate fixed mode after scrolling past the header + if (currentScrollY > headerHeight) { + if (currentScrollY < lastScrollY) { + // Scrolling up - show fixed header + if (!isFixed) { + spacer.style.height = headerHeight + 'px'; + spacer.classList.add('active'); + topSection.classList.add('fixed'); + // Start hidden, then show + topSection.classList.add('hidden'); + requestAnimationFrame(() => { + topSection.classList.remove('hidden'); + }); + isFixed = true; + } else { + topSection.classList.remove('hidden'); + } + } else if (currentScrollY > lastScrollY && isFixed) { + // Scrolling down while fixed - hide it + topSection.classList.add('hidden'); + } + } + + lastScrollY = currentScrollY; + }, { passive: true }); + + // Stat card click handler + document.addEventListener('click', (e) => { + const card = e.target.closest('.card.clickable'); + if (!card) return; + + const filterType = card.dataset.filter; + const status = card.dataset.status; + const type = card.dataset.type; + + if (filterType === 'domain') { + // Remove active from domain cards only + document.querySelectorAll('.card.clickable[data-filter="domain"]').forEach(c => c.classList.remove('active')); + card.classList.add('active'); + domainFilter = status || 'all'; + + // Update placeholder + const searchInput = document.getElementById('searchInput'); + searchInput.placeholder = domainFilter === 'all' ? 'Search domains...' : `Showing ${domainFilter} domains...`; + + // Reload TLD list with new filter + loadFeeds(searchQuery); + } else if (filterType === 'feed') { + const wasActive = card.classList.contains('active'); + + if (status === 'all') { + // ALL card toggles exclusion mode + if (wasActive) { + card.classList.remove('active'); + feedFilter.allSelected = false; + } else { + card.classList.add('active'); + feedFilter.allSelected = true; + } + } else if (status) { + // Status card (pass, skip, hold, dead) - multi-select + if (wasActive) { + card.classList.remove('active'); + feedFilter.statuses = feedFilter.statuses.filter(s => s !== status); + } else { + card.classList.add('active'); + feedFilter.statuses.push(status); + } + } else if (type) { + // Type card (rss, atom, json, unknown, empty) - multi-select + if (wasActive) { + card.classList.remove('active'); + feedFilter.types = feedFilter.types.filter(t => t !== type); + } else { + card.classList.add('active'); + feedFilter.types.push(type); + } + } + + // Reload TLD list with feed filter + loadFeeds(searchQuery); + } + }); + + // Refresh only expanded TLD sections with new domain filter + function refreshExpandedTLDs() { + const expandedContainer = document.getElementById('expandedTLDContent'); + if (expandedContainer && expandedContainer.style.display !== 'none' && expandedContainer.dataset.tld) { + // Mark as needing reload and reload + expandedContainer.dataset.loaded = 'false'; + loadTLDDomains(expandedContainer, searchQuery); + } + } + + // Apply feed filter to currently visible feeds + function applyFeedFilter() { + document.querySelectorAll('.inline-feed-block').forEach(block => { + const feedStatus = block.dataset.status || 'hold'; + const feedType = block.dataset.type || 'unknown'; + + let show = true; + if (feedFilter.status !== 'all' && feedStatus !== feedFilter.status) { + show = false; + } + if (feedFilter.type && feedType !== feedFilter.type) { + show = false; + } + + block.style.display = show ? 'block' : 'none'; + }); + } + + // Event delegation for domain-spacer clicks (toggle feeds) + document.addEventListener('click', (e) => { + const spacer = e.target.closest('.domain-spacer'); + if (spacer) { + const block = spacer.closest('.domain-block'); + if (block) { + const feedsDiv = block.querySelector('.domain-feeds'); + if (feedsDiv) { + const isVisible = feedsDiv.style.display !== 'none'; + feedsDiv.style.display = isVisible ? 'none' : 'block'; + if (!isVisible) { + feedsDiv.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); + } + }); + } + } + } + } + }); + + // Event delegation for feed-url-toggle clicks (toggle feed info) + document.addEventListener('click', (e) => { + const urlToggle = e.target.closest('.feed-url-toggle'); + if (urlToggle) { + const feedBlock = urlToggle.closest('.inline-feed-block'); + if (feedBlock) { + const infoDiv = feedBlock.querySelector('.feed-info'); + if (infoDiv) { + const isVisible = infoDiv.style.display !== 'none'; + infoDiv.style.display = isVisible ? 'none' : 'block'; + if (!isVisible && !infoDiv.dataset.loaded) { + infoDiv.dataset.loaded = 'true'; + loadFeedInfo(feedBlock.dataset.url, infoDiv); + } + } + } + } + }); + + // Event delegation for feed-title-toggle and feed-filler-toggle clicks (toggle items) + document.addEventListener('click', (e) => { + const titleToggle = e.target.closest('.feed-title-toggle'); + const fillerToggle = e.target.closest('.feed-filler-toggle'); + if (titleToggle || fillerToggle) { + const feedBlock = (titleToggle || fillerToggle).closest('.inline-feed-block'); + if (feedBlock) { + const itemsDiv = feedBlock.querySelector('.feed-items'); + if (itemsDiv) { + const isVisible = itemsDiv.style.display !== 'none'; + itemsDiv.style.display = isVisible ? 'none' : 'block'; + } + } + } + }); + + // Load feed info + async function loadFeedInfo(feedUrl, infoDiv) { + infoDiv.innerHTML = 'Loading...'; + try { + const resp = await fetch(`/api/feedInfo?url=${encodeURIComponent(feedUrl)}`); + if (!resp.ok) throw new Error('Failed to load'); + const f = await resp.json(); + + let html = '
'; + const fields = [ + ['URL', f.url], + ['Title', f.title], + ['Description', f.description], + ['Type', f.type], + ['Language', f.language], + ['Site URL', f.siteUrl], + ['Status', f.status], + ['Last Error', f.lastError], + ['Item Count', f.itemCount], + ['Oldest Item', f.oldestItemDate], + ['Newest Item', f.newestItemDate], + ['Discovered', f.discoveredAt], + ['Last Checked', f.lastCheckedAt], + ['Next Check', f.nextCheckAt], + ['Publish Status', f.publishStatus], + ['Publish Account', f.publishAccount], + ]; + fields.forEach(([label, value]) => { + if (value != null && value !== '' && value !== 0) { + html += `${escapeHtml(label)}:${escapeHtml(String(value))}`; + } + }); + html += '
'; + infoDiv.innerHTML = html; + } catch (err) { + infoDiv.innerHTML = `Error: ${escapeHtml(err.message)}`; + } + } + + // Load feed items + async function loadFeedItems(feedUrl, itemsDiv) { + itemsDiv.innerHTML = 'Loading...'; + try { + const resp = await fetch(`/api/feedItems?url=${encodeURIComponent(feedUrl)}&limit=50`); + if (!resp.ok) throw new Error('Failed to load'); + const items = await resp.json(); + + if (!items || items.length === 0) { + // Just clear the items area, keep the feed visible + itemsDiv.innerHTML = ''; + return; + } + + let html = ''; + items.forEach(item => { + const date = item.pub_date ? new Date(item.pub_date).toLocaleDateString() : ''; + html += `
`; + html += `
${escapeHtml(date)} 
`; + if (item.link) { + html += `${escapeHtml(item.title || item.link)}`; + } else { + html += `${escapeHtml(item.title || '(no title)')}`; + } + html += '
'; + }); + itemsDiv.innerHTML = html; + } catch (err) { + itemsDiv.innerHTML = `Error: ${escapeHtml(err.message)}`; + } + } + + // Status colors + const statusConfig = { + hold: { color: '#f90', bg: '#330', border: '#550' }, + skip: { color: '#f66', bg: '#400', border: '#600' }, + pass: { color: '#0f0', bg: '#040', border: '#060' } + }; + + // Render status buttons + function renderStatusBtns(currentStatus, type, id) { + const order = ['pass', 'hold', 'skip']; + let html = '
'; + order.forEach((s, i) => { + const cfg = statusConfig[s]; + const isActive = s === currentStatus; + const bg = isActive ? cfg.bg : '#1a1a1a'; + const border = isActive ? cfg.border : '#333'; + const color = isActive ? cfg.color : '#ccc'; + html += ``; + }); + html += '
'; + return html; + } + + // Render TLD section header + function renderTLDHeader(tld) { + return `
+
+ .${escapeHtml(tld)} +
+
`; + } + + function renderTLDFooter(tld) { + return ``; + } + + function closeTLDSection(container, tld) { + const tldContent = container.querySelector(`.tld-section[data-tld="${tld}"] .tld-content`); + if (tldContent) { + tldContent.insertAdjacentHTML('beforeend', renderTLDFooter(tld)); + } + } + + // Event delegation for TLD clicks (toggle section) + document.addEventListener('click', (e) => { + const tldHeader = e.target.closest('.tld-header'); + const tldFooter = e.target.closest('.tld-footer'); + const expandedContainer = document.getElementById('expandedTLDContent'); + + // Handle clicks in expanded container header + if (tldHeader && tldHeader.closest('#expandedTLDContent')) { + // Close the expanded content + const currentSection = document.querySelector('.tld-section.expanded'); + if (currentSection) { + currentSection.classList.remove('expanded'); + } + expandedContainer.style.display = 'none'; + expandedContainer.innerHTML = ''; + currentOpenTLD = null; + // Show TLD list again + const domainList = document.querySelector('.domain-list'); + if (domainList) domainList.style.display = ''; + updateStats(); // Revert to search or all stats + return; + } + + // Handle clicks on TLD cards + if (tldHeader || tldFooter) { + const section = (tldHeader || tldFooter).closest('.tld-section'); + if (section) { + const tld = section.dataset.tld; + const isExpanded = section.classList.contains('expanded'); + + if (isExpanded) { + // Closing this TLD + section.classList.remove('expanded'); + expandedContainer.style.display = 'none'; + expandedContainer.innerHTML = ''; + currentOpenTLD = null; + // Show TLD list again + const domainList = document.querySelector('.domain-list'); + if (domainList) domainList.style.display = ''; + updateStats(); // Revert to search or all stats + } else { + // Close any other open TLD first + document.querySelectorAll('.tld-section.expanded').forEach(s => { + s.classList.remove('expanded'); + }); + + // Opening this TLD + section.classList.add('expanded'); + currentOpenTLD = tld; + // Hide TLD list + const domainList = document.querySelector('.domain-list'); + if (domainList) domainList.style.display = 'none'; + // Show TLD stats (filtered by search if active) + const currentSearch = document.getElementById('searchInput').value.trim(); + updateStatsForTLD(tld, currentSearch); + + // Set up expanded container with header + expandedContainer.innerHTML = ` +
+ .${escapeHtml(tld)} +
+
+
Loading...
+
+ `; + expandedContainer.style.display = 'block'; + expandedContainer.dataset.tld = tld; + expandedContainer.dataset.loaded = 'false'; + + // Load domains + loadTLDDomains(expandedContainer, searchQuery); + + // Scroll to expanded container + expandedContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + } + }); + + // Update stats for a specific TLD (optionally filtered by search) + async function updateStatsForTLD(tld, search = '') { + try { + let url = `/api/tldStats?tld=${encodeURIComponent(tld)}`; + if (search) { + url += `&search=${encodeURIComponent(search)}`; + } + const resp = await fetch(url); + if (!resp.ok) return; + const stats = await resp.json(); + + document.getElementById('totalDomains').textContent = commaFormat(stats.total_domains || 0); + document.getElementById('passDomains').textContent = commaFormat(stats.pass_domains || 0); + document.getElementById('skipDomains').textContent = commaFormat(stats.skip_domains || 0); + document.getElementById('holdDomains').textContent = commaFormat(stats.hold_domains || 0); + document.getElementById('deadDomains').textContent = commaFormat(stats.dead_domains || 0); + + document.getElementById('totalFeeds').textContent = commaFormat(stats.total_feeds || 0); + document.getElementById('aliveFeeds').textContent = commaFormat(stats.alive_feeds || 0); + document.getElementById('publishFeeds').textContent = commaFormat(stats.publish_feeds || 0); + document.getElementById('skipFeeds').textContent = commaFormat(stats.skip_feeds || 0); + document.getElementById('holdFeeds').textContent = commaFormat(stats.hold_feeds || 0); + document.getElementById('deadFeeds').textContent = commaFormat(stats.dead_feeds || 0); + document.getElementById('emptyFeeds').textContent = commaFormat(stats.empty_feeds || 0); + document.getElementById('rssFeeds').textContent = commaFormat(stats.rss_feeds || 0); + document.getElementById('atomFeeds').textContent = commaFormat(stats.atom_feeds || 0); + document.getElementById('jsonFeeds').textContent = commaFormat(stats.json_feeds || 0); + document.getElementById('unknownFeeds').textContent = commaFormat(stats.unknown_feeds || 0); + + document.getElementById('updatedAt').textContent = search ? `Search "${search}" in .${tld}` : `Stats for .${tld}`; + } catch (err) { + console.error('TLD stats update failed:', err); + } + } + + // Update stats for search results + async function updateStatsForSearch(query) { + try { + const resp = await fetch(`/api/searchStats?search=${encodeURIComponent(query)}`); + if (!resp.ok) { + console.error('Search stats failed:', resp.status); + return; + } + const stats = await resp.json(); + + document.getElementById('totalDomains').textContent = commaFormat(stats.total_domains || 0); + document.getElementById('passDomains').textContent = commaFormat(stats.pass_domains || 0); + document.getElementById('skipDomains').textContent = commaFormat(stats.skip_domains || 0); + document.getElementById('holdDomains').textContent = commaFormat(stats.hold_domains || 0); + document.getElementById('deadDomains').textContent = commaFormat(stats.dead_domains || 0); + + document.getElementById('totalFeeds').textContent = commaFormat(stats.total_feeds || 0); + document.getElementById('aliveFeeds').textContent = commaFormat(stats.alive_feeds || 0); + document.getElementById('publishFeeds').textContent = commaFormat(stats.publish_feeds || 0); + document.getElementById('skipFeeds').textContent = commaFormat(stats.skip_feeds || 0); + document.getElementById('holdFeeds').textContent = commaFormat(stats.hold_feeds || 0); + document.getElementById('deadFeeds').textContent = commaFormat(stats.dead_feeds || 0); + document.getElementById('emptyFeeds').textContent = commaFormat(stats.empty_feeds || 0); + document.getElementById('rssFeeds').textContent = commaFormat(stats.rss_feeds || 0); + document.getElementById('atomFeeds').textContent = commaFormat(stats.atom_feeds || 0); + document.getElementById('jsonFeeds').textContent = commaFormat(stats.json_feeds || 0); + document.getElementById('unknownFeeds').textContent = commaFormat(stats.unknown_feeds || 0); + + document.getElementById('updatedAt').textContent = `Search: "${query}"`; + } catch (err) { + console.error('Search stats update failed:', err); + } + } + + // Render domain row with feeds + function renderDomainRow(d) { + const status = d.status || 'hold'; + + const fullDomain = d.tld ? d.host + '.' + d.tld : d.host; + let html = `
`; + html += `
`; + html += renderStatusBtns(status, 'domain', fullDomain); + html += `${escapeHtml(fullDomain)}`; + + 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'; + const feedType = f.type || 'unknown'; + html += `
`; + html += `
`; + + html += `${escapeHtml(f.language || '')} `; + html += renderStatusBtns(feedStatus, 'feed', f.url); + + 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 += '
'; + } + 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 }; + } + + function clearInfiniteScroll() { + infiniteScrollState = null; + } + + async function checkInfiniteScroll() { + if (!infiniteScrollState || infiniteScrollState.ended || isLoadingMore) return; + const scrollY = window.scrollY + window.innerHeight; + const docHeight = document.documentElement.scrollHeight; + if (scrollY > docHeight - 500) { + isLoadingMore = true; + await infiniteScrollState.loadMore(); + isLoadingMore = false; + } + } + + window.addEventListener('scroll', checkInfiniteScroll); + + // Load and display feeds with lazy-loading TLD sections + let tldObserver = null; + + async function loadFeeds(query = '') { + const output = document.getElementById('output'); + output.innerHTML = '
Loading TLDs...
'; + + // Disconnect previous observer if any + if (tldObserver) { + tldObserver.disconnect(); + } + + try { + // Fetch TLDs with optional domain status filter, feed filter, and search + let tldsUrl = '/api/tlds'; + const params = []; + if (domainFilter !== 'all') { + params.push(`status=${domainFilter}`); + } + // Add feed filter params if any are selected + if (feedFilter.allSelected || feedFilter.statuses.length > 0 || feedFilter.types.length > 0) { + if (feedFilter.allSelected) { + params.push('feedMode=exclude'); + } else { + params.push('feedMode=include'); + } + if (feedFilter.statuses.length > 0) { + params.push(`feedStatuses=${feedFilter.statuses.join(',')}`); + } + if (feedFilter.types.length > 0) { + params.push(`feedTypes=${feedFilter.types.join(',')}`); + } + } + if (query) { + params.push(`search=${encodeURIComponent(query)}`); + } + if (params.length > 0) { + tldsUrl += '?' + params.join('&'); + } + const tldsResp = await fetch(tldsUrl); + if (!tldsResp.ok) { + const errText = await tldsResp.text(); + throw new Error(`HTTP ${tldsResp.status}: ${errText}`); + } + const tlds = await tldsResp.json(); + + if (!tlds || tlds.length === 0) { + // Update stats for empty results + if (query) { + await updateStatsForSearch(query); + } else { + await updateStats(); + } + document.getElementById('infiniteLoader').textContent = query ? 'No matches found' : 'No feeds found'; + return; + } + + const container = output.querySelector('.domain-list'); + + // Render all TLD sections as card placeholders + tlds.forEach(t => { + const tld = t.tld || 'unknown'; + container.insertAdjacentHTML('beforeend', ` +
+
+ .${escapeHtml(tld)} +
+ +
+ `); + }); + + document.getElementById('infiniteLoader').textContent = ''; + + // Auto-expand if single TLD match, otherwise update stats for search/all + if (tlds.length === 1) { + const tld = tlds[0].tld; + const expandedContainer = document.getElementById('expandedTLDContent'); + const section = output.querySelector('.tld-section'); + + if (section && expandedContainer) { + // Mark as expanded + section.classList.add('expanded'); + currentOpenTLD = tld; + // Hide TLD list + const domainList = document.querySelector('.domain-list'); + if (domainList) domainList.style.display = 'none'; + + // Set up expanded container + expandedContainer.innerHTML = ` +
+ .${escapeHtml(tld)} +
+
+
Loading...
+
+ `; + expandedContainer.style.display = 'block'; + expandedContainer.dataset.tld = tld; + expandedContainer.dataset.loaded = 'false'; + + // Load domains + loadTLDDomains(expandedContainer, query); + + // Show TLD stats (filtered by search if active) + await updateStatsForTLD(tld, query); + } + } else { + // Multiple TLDs - show search or global stats + if (query) { + await updateStatsForSearch(query); + } else { + await updateStats(); + } + } + + } catch (err) { + document.getElementById('infiniteLoader').textContent = 'Error: ' + err.message; + } + } + + // Load domains for a specific TLD section + async function loadTLDDomains(section, query = '') { + const tld = section.dataset.tld; + section.dataset.loaded = 'loading'; + + try { + let url = `/api/domains?tld=${encodeURIComponent(tld)}&limit=500`; + if (domainFilter !== 'all') { + url += `&status=${domainFilter}`; + } + if (query) { + url += `&search=${encodeURIComponent(query)}`; + } + // Apply feed filter if any feed cards are selected + if (feedFilter.allSelected || feedFilter.statuses.length > 0 || feedFilter.types.length > 0) { + if (feedFilter.allSelected) { + url += '&feedMode=exclude'; + } else { + url += '&feedMode=include'; + } + if (feedFilter.statuses.length > 0) { + url += `&feedStatuses=${feedFilter.statuses.join(',')}`; + } + if (feedFilter.types.length > 0) { + url += `&feedTypes=${feedFilter.types.join(',')}`; + } + } + + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const domains = await resp.json(); + + const content = section.querySelector('.tld-content'); + content.innerHTML = ''; + + if (!domains || domains.length === 0) { + content.innerHTML = '
No domains with feeds
'; + } else { + domains.forEach(d => { + content.insertAdjacentHTML('beforeend', renderDomainRow(d)); + }); + // Add footer + content.insertAdjacentHTML('beforeend', renderTLDFooter(tld)); + attachStatusHandlers(content); + + // Load items for all feeds + content.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); + } + }); + } + + section.dataset.loaded = 'true'; + } catch (err) { + const content = section.querySelector('.tld-content'); + content.innerHTML = `
Error: ${escapeHtml(err.message)}
`; + section.dataset.loaded = 'false'; + } + } + + // Search handler + const searchInput = document.getElementById('searchInput'); + function doSearch() { + searchQuery = searchInput.value.trim(); + loadFeeds(searchQuery); + } + + // Search on button click + document.getElementById('searchBtn').addEventListener('click', doSearch); + + // Clear button - clears search and resets all filters + document.getElementById('clearBtn').addEventListener('click', () => { + searchInput.value = ''; + searchQuery = ''; + // Reset filters to default + domainFilter = 'all'; + feedFilter = { allSelected: false, statuses: [], types: [] }; + // Reset active card styling + document.querySelectorAll('.card.clickable.active').forEach(c => c.classList.remove('active')); + document.querySelector('.card.clickable[data-filter="domain"][data-status="all"]')?.classList.add('active'); + searchInput.placeholder = 'Search domains...'; + // Close any expanded TLD + currentOpenTLD = null; + const expandedContainer = document.getElementById('expandedTLDContent'); + if (expandedContainer) { + expandedContainer.style.display = 'none'; + expandedContainer.innerHTML = ''; + } + // Show TLD list if hidden + const domainList = document.querySelector('.domain-list'); + if (domainList) domainList.style.display = ''; + // Reload and update stats + loadFeeds(); + }); + + // Search on Enter key + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + doSearch(); + } + }); + + // Initial load - set default active cards and load + document.querySelector('.card.clickable[data-filter="domain"][data-status="all"]')?.classList.add('active'); + loadFeeds(); + + // Update stats periodically + async function updateStats() { + // Check actual input value for current search state + const currentSearch = document.getElementById('searchInput')?.value.trim() || ''; + + // Priority: open TLD > search query > all + if (currentOpenTLD) { + updateStatsForTLD(currentOpenTLD, currentSearch); + return; + } + if (currentSearch) { + updateStatsForSearch(currentSearch); + return; + } + + try { + const resp = await fetch('/api/stats'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + 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('deadDomains').textContent = commaFormat(stats.dead_domains); + document.getElementById('domainCheckRate').textContent = commaFormat(stats.domain_check_rate); + document.getElementById('feedCrawlRate').textContent = commaFormat(stats.feed_crawl_rate); + document.getElementById('feedCheckRate').textContent = commaFormat(stats.feed_check_rate); + document.getElementById('totalFeeds').textContent = commaFormat(stats.total_feeds); + document.getElementById('aliveFeeds').textContent = commaFormat(stats.alive_feeds); + document.getElementById('publishFeeds').textContent = commaFormat(stats.publish_feeds); + document.getElementById('skipFeeds').textContent = commaFormat(stats.skip_feeds); + document.getElementById('holdFeeds').textContent = commaFormat(stats.hold_feeds); + document.getElementById('deadFeeds').textContent = commaFormat(stats.dead_feeds); + document.getElementById('emptyFeeds').textContent = commaFormat(stats.empty_feeds); + document.getElementById('rssFeeds').textContent = commaFormat(stats.rss_feeds); + document.getElementById('atomFeeds').textContent = commaFormat(stats.atom_feeds); + document.getElementById('jsonFeeds').textContent = commaFormat(stats.json_feeds); + document.getElementById('unknownFeeds').textContent = commaFormat(stats.unknown_feeds); + document.getElementById('updatedAt').textContent = 'All TLDs - ' + new Date().toLocaleTimeString(); + } catch (err) { + console.error('Stats update failed:', err); + } + } + + setInterval(updateStats, 60000); +} + +document.addEventListener('DOMContentLoaded', initDashboard);