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 = '