document.addEventListener('DOMContentLoaded', function() { // Mobile menu functionality const hamburgerMenu = document.getElementById('hamburgerMenu'); const sidebarClose = document.getElementById('sidebarClose'); const sidebar = document.getElementById('sidebar'); const userMenuButton = document.getElementById('userMenuButton'); const userMenuDropdown = document.getElementById('userMenuDropdown'); const clearContentButton = document.getElementById('clearContentButton'); // Create a hidden file input for OPML upload const opmlFileInput = document.createElement('input'); opmlFileInput.type = 'file'; opmlFileInput.accept = '.opml,.xml'; opmlFileInput.style.display = 'none'; document.body.appendChild(opmlFileInput); function toggleSidebar() { sidebar.classList.toggle('active'); } // Mobile menu handlers hamburgerMenu.addEventListener('click', toggleSidebar); sidebarClose.addEventListener('click', toggleSidebar); // Close sidebar when clicking outside on mobile document.addEventListener('click', function(event) { const isMobile = window.innerWidth <= 768; if (isMobile && !sidebar.contains(event.target) && !hamburgerMenu.contains(event.target) && sidebar.classList.contains('active')) { sidebar.classList.remove('active'); } }); // Error modal functionality const errorModal = document.getElementById('errorModal'); const errorModalMessage = document.getElementById('errorModalMessage'); const errorModalTitle = document.getElementById('errorModalTitle'); const errorModalClose = document.getElementById('errorModalClose'); function showStatusModal(message, isError = true) { errorModalMessage.textContent = message; const modalHeader = errorModal.querySelector('.modal-header'); if (isError) { modalHeader.classList.add('error'); errorModalTitle.innerHTML = ' Error'; } else { modalHeader.classList.remove('error'); errorModalTitle.innerHTML = ' Success'; } errorModal.classList.add('show'); } function hideStatusModal() { errorModal.classList.remove('show'); } errorModalClose.addEventListener('click', hideStatusModal); // Close error modal when clicking outside errorModal.addEventListener('click', (e) => { if (e.target === errorModal) { hideStatusModal(); } }); // Close error modal when pressing escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && errorModal.classList.contains('show')) { hideStatusModal(); } }); // OPML import handlers opmlFileInput.addEventListener('change', async (e) => { if (e.target.files.length > 0) { const file = e.target.files[0]; const formData = new FormData(); formData.append('file', file); try { const response = await fetch('/import/opml', { method: 'POST', body: formData }); if (response.ok) { const result = await response.json(); if (result.success) { showJobStatus("Validating OPML file..."); if (result.job_id) { pollJobStatus(result.job_id); } } else { showError('OPML import failed: ' + result.message); } } else { showError('Failed to import OPML file. Please try again.'); } } catch (error) { console.error('OPML import failed:', error); showError('Failed to import OPML file. Please try again.'); } } // Clear the input so the same file can be selected again e.target.value = ''; }); function showJobStatus(message, isCompleted = false) { const jobStatus = document.getElementById('jobStatus'); const jobStatusText = jobStatus.querySelector('.job-status-text'); jobStatus.style.display = 'flex'; jobStatusText.textContent = message; if (isCompleted) { jobStatus.classList.add('completed'); // Hide after 5 seconds setTimeout(() => { jobStatus.classList.add('fade-out'); setTimeout(() => { jobStatus.style.display = 'none'; jobStatus.classList.remove('completed', 'fade-out'); }, 500); }, 5000); } else { jobStatus.classList.remove('completed'); } } async function pollJobStatus(jobId) { const maxAttempts = 30; // 30 attempts * 2 second delay = 1 minute maximum let attempts = 0; const poll = async () => { try { const response = await fetch(`/jobs/${jobId}`); if (response.ok) { const status = await response.json(); if (status.status === 'completed') { showJobStatus(`Import completed. Successfully imported ${status.completed} feeds.`, true); handleFeeds(); return; } else if (status.status === 'in_progress') { showJobStatus(`Importing feeds... ${status.completed}/${status.total} completed`); if (attempts++ < maxAttempts) { setTimeout(poll, 2000); // Poll every 2 seconds } else { showJobStatus('Import taking longer than expected. Check feeds list in a few minutes.', true); setTimeout(handleFeeds, 5000); } } } else { throw new Error('Failed to fetch job status'); } } catch (error) { console.error('Failed to poll job status:', error); showError('Failed to check import status. Please refresh the page.'); } }; poll(); } // User menu handlers userMenuButton.addEventListener('click', (e) => { e.stopPropagation(); userMenuDropdown.classList.toggle('show'); }); document.addEventListener('click', (e) => { if (!e.target.closest('.user-menu')) { userMenuDropdown.classList.remove('show'); } }); userMenuDropdown.addEventListener('click', (e) => { e.stopPropagation(); }); document.getElementById('importOpmlButton').addEventListener('click', () => { opmlFileInput.click(); userMenuDropdown.classList.remove('show'); }); document.getElementById('settingsButton').addEventListener('click', () => { window.location.href = '/settings'; userMenuDropdown.classList.remove('show'); }); document.getElementById('logoutButton').addEventListener('click', async () => { try { const response = await fetch('/logout', { method: 'POST', }); if (response.ok) { window.location.href = '/login'; } } catch (error) { console.error('Logout failed:', error); } }); // Clear content button handler clearContentButton.addEventListener('click', () => { const mainContent = document.getElementById('main-content'); mainContent.innerHTML = ''; // Remove any active states from feed list document.querySelectorAll('#feedList .feed-item.active').forEach(item => { item.classList.remove('active'); }); }); // Initialize feeds handleFeeds(); }); // Fetch and display feeds async function fetchFeeds() { try { const response = await fetch('/feeds'); if (response.ok) { const feeds = await response.json(); return feeds; } else { console.error('Failed to load feeds:', response.status); return null; } } catch (error) { console.error('Error loading feeds:', error); return null; } } // Close any open feed menus function closeAllFeedMenus() { document.querySelectorAll('.feed-menu.show').forEach(menu => { menu.classList.remove('show'); }); } // Add click handler to close menus when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('.feed-menu') && !e.target.closest('.feed-menu-button')) { closeAllFeedMenus(); } }); function formatDate(dateStr) { if (!dateStr) return 'Not available'; const date = new Date(dateStr); return date.toLocaleString(); } function renderFeedEntries(entries) { const mainContent = document.querySelector('#main-content'); mainContent.innerHTML = ''; entries.forEach(entry => { const entryDiv = document.createElement('article'); entryDiv.className = 'feed-entry'; if (entry.marked_read) { entryDiv.classList.add('read'); } const title = document.createElement('h2'); title.className = 'feed-entry-title'; const readToggle = document.createElement('button'); readToggle.className = 'read-toggle'; readToggle.title = entry.marked_read ? 'Mark as unread' : 'Mark as read'; readToggle.innerHTML = entry.marked_read ? '' : ''; readToggle.onclick = async () => { try { const response = await fetch(`/entries/${entry.local_id}/state`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ read: !entry.marked_read }) }); if (response.ok) { entry.marked_read = !entry.marked_read; readToggle.title = entry.marked_read ? 'Mark as unread' : 'Mark as read'; readToggle.innerHTML = entry.marked_read ? '' : ''; entryDiv.classList.toggle('read', entry.marked_read); } else { console.error('Failed to update read state:', response.status); } } catch (error) { console.error('Failed to update read state:', error); } }; title.appendChild(readToggle); const titleLink = document.createElement('a'); titleLink.href = entry.link || '#'; titleLink.target = '_blank'; titleLink.textContent = entry.title; titleLink.onclick = () => { if (!entry.marked_read) { // Mark as read in the background, don't wait for it fetch(`/entries/${entry.local_id}/state`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ read: true }) }).then(response => { if (response.ok) { entry.marked_read = true; readToggle.title = 'Mark as unread'; readToggle.innerHTML = ''; entryDiv.classList.add('read'); } else { console.error('Failed to update read state:', response.status); } }).catch(error => { console.error('Failed to update read state:', error); }); } // Let the default link behavior happen immediately }; title.appendChild(titleLink); const meta = document.createElement('div'); meta.className = 'feed-entry-meta'; if (entry.published) { const published = document.createElement('span'); published.textContent = `Published: ${formatDate(entry.published)}`; meta.appendChild(published); } if (entry.updated) { const updated = document.createElement('span'); updated.textContent = `Updated: ${formatDate(entry.updated)}`; meta.appendChild(updated); } const summary = document.createElement('div'); summary.className = 'feed-entry-summary'; summary.textContent = entry.summary; entryDiv.appendChild(title); entryDiv.appendChild(meta); entryDiv.appendChild(summary); mainContent.appendChild(entryDiv); }); } function openFeed(feed) { const name = document.createElement('span'); name.className = 'feed-name'; name.textContent = feed.name; const spinner = document.createElement('div'); spinner.className = 'feed-spinner'; name.appendChild(spinner); // Create unread count element const unreadCount = document.createElement('span'); unreadCount.className = 'feed-unread-count'; unreadCount.title = 'Unread entry count'; if (feed.unread_count > 0) { unreadCount.textContent = feed.unread_count; unreadCount.style.display = 'inline'; } name.appendChild(unreadCount); name.onclick = async () => { name.classList.add('loading'); try { const response = await fetch(`/poll/${feed.feed_id}`, { method: 'POST' }); if (response.ok) { const data = await response.json(); console.log('Feed poll response:', data); renderFeedEntries(data.entries); // Update the unread count feed.unread_count = data.unread_count; if (feed.unread_count > 0) { unreadCount.textContent = feed.unread_count; unreadCount.style.display = 'inline'; } else { unreadCount.style.display = 'none'; } // Update the top bar title const topBarTitle = document.querySelector('.top-bar-title'); topBarTitle.innerHTML = `RSS Reader - ${feed.name}`; // Update the browser document title document.title = `RSS Reader - ${feed.name}`; // Close sidebar if on mobile if (window.innerWidth <= 768) { const sidebar = document.getElementById('sidebar'); sidebar.classList.remove('active'); } } else { console.error('Failed to poll feed:', response.status); } } catch (error) { console.error('Error polling feed:', error); } finally { name.classList.remove('loading'); } }; const menuButton = document.createElement('button'); menuButton.className = 'feed-menu-button'; menuButton.innerHTML = ''; menuButton.title = 'Feed options'; menuButton.onclick = (e) => { e.stopPropagation(); closeAllFeedMenus(); menu.classList.toggle('show'); }; const menu = document.createElement('div'); menu.className = 'feed-menu'; const copyItem = document.createElement('a'); copyItem.className = 'feed-menu-item copy'; copyItem.innerHTML = ' Copy feed URL'; copyItem.onclick = async (e) => { e.stopPropagation(); try { await navigator.clipboard.writeText(feed.url); // Briefly show success feedback const originalText = copyItem.innerHTML; copyItem.innerHTML = ' Copied!'; setTimeout(() => { copyItem.innerHTML = originalText; }, 1500); } catch (error) { console.error('Failed to copy URL:', error); } menu.classList.remove('show'); }; const deleteItem = document.createElement('a'); deleteItem.className = 'feed-menu-item delete'; deleteItem.innerHTML = ' Remove Feed'; deleteItem.onclick = async (e) => { e.stopPropagation(); if (confirm(`Are you sure you want to delete "${feed.name}"?`)) { try { const response = await fetch(`/feeds/${feed.feed_id}`, { method: 'DELETE', }); if (response.ok) { handleFeeds(); } else { console.error('Failed to delete feed:', response.status); } } catch (error) { console.error('Error deleting feed:', error); } } menu.classList.remove('show'); }; menu.appendChild(copyItem); menu.appendChild(deleteItem); name.appendChild(menuButton); name.appendChild(menu); return name; } async function handleFeeds() { const feeds = await fetchFeeds(); const feedList = document.getElementById('feedList'); if (feeds) { console.log('Loaded feeds:', feeds); feedList.innerHTML = ''; if (feeds.length === 0) { const emptyMessage = document.createElement('div'); emptyMessage.className = 'feed-empty'; emptyMessage.textContent = 'No feeds added yet'; feedList.appendChild(emptyMessage); } else { // Group feeds by category const feedsByCategory = {}; const uncategorizedFeeds = []; feeds.forEach(feed => { const category = feed.categorization.length > 0 ? feed.categorization[0] : null; if (category) { if (!feedsByCategory[category]) { feedsByCategory[category] = []; } feedsByCategory[category].push(feed); } else { uncategorizedFeeds.push(feed); } }); // Sort categories alphabetically, but keep "Uncategorized" at the end const sortedCategories = Object.keys(feedsByCategory).sort((a, b) => a.localeCompare(b)); sortedCategories.push(""); feedsByCategory[""] = uncategorizedFeeds; // Create category sections sortedCategories.forEach(category => { const categorySection = document.createElement('div'); categorySection.className = 'feed-category'; const categoryHeader = document.createElement('h3'); categoryHeader.className = 'feed-category-header'; categoryHeader.textContent = category; categorySection.appendChild(categoryHeader); // Add feeds for this category feedsByCategory[category].forEach(feed => { categorySection.appendChild(openFeed(feed)); }); feedList.appendChild(categorySection); }); } } else { feedList.innerHTML = '
Failed to load feeds
'; } } // Modal functionality const modal = document.getElementById('addFeedModal'); const addFeedButton = document.getElementById('addFeedButton'); const cancelButton = document.getElementById('cancelAddFeed'); const confirmButton = document.getElementById('confirmAddFeed'); const addFeedForm = document.getElementById('addFeedForm'); const errorMessage = document.getElementById('feedErrorMessage'); const loadingMessage = document.getElementById('loadingMessage'); function showModal() { modal.classList.add('show'); errorMessage.style.display = 'none'; loadingMessage.style.display = 'none'; addFeedForm.reset(); confirmButton.disabled = false; } function hideModal() { modal.classList.remove('show'); errorMessage.style.display = 'none'; loadingMessage.style.display = 'none'; addFeedForm.reset(); confirmButton.disabled = false; } function showError(message) { errorMessage.textContent = message; errorMessage.style.display = 'block'; loadingMessage.style.display = 'none'; confirmButton.disabled = false; } function showLoading() { loadingMessage.style.display = 'block'; errorMessage.style.display = 'none'; confirmButton.disabled = true; } addFeedButton.addEventListener('click', showModal); cancelButton.addEventListener('click', hideModal); // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { hideModal(); } }); // Close modal when pressing escape document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('show')) { hideModal(); } }); confirmButton.addEventListener('click', async () => { const url = document.getElementById('feedUrl').value.trim(); if (!url) { showError('Please enter a feed URL'); return; } showLoading(); try { const response = await fetch('/feeds', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ url }), }); if (response.ok) { hideModal(); // Refresh the feed list handleFeeds(); } else { switch (response.status) { case 409: showError('You already have this feed added'); break; case 422: showError('Invalid feed URL. Please make sure it\'s a valid RSS or Atom feed.'); break; default: showError('Failed to add feed. Please try again.'); } } } catch (error) { console.error('Add feed failed:', error); showError('An error occurred. Please try again.'); } });