import './style.css'; import { apiFetch } from './api'; import { store } from './store'; import type { FilterType } from './store'; import { router } from './router'; import type { Feed, Item, Category } from './types'; import { createFeedItem } from './components/FeedItem'; // Extend Window interface for app object (keeping for compatibility if needed, but removing inline dependencies) declare global { interface Window { app: any; } } // Global App State let activeItemId: number | null = null; // Cache elements (initialized in renderLayout) let appEl: HTMLDivElement | null = null; let itemObserver: IntersectionObserver | null = null; // Initial Layout (v2-style 2-pane) export function renderLayout() { appEl = document.querySelector('#app'); if (!appEl) return; appEl.className = `theme-${store.theme} font-${store.fontTheme}`; appEl.innerHTML = `
  • Unread
  • All
  • Starred

Tags ▶

    Feeds ▶

      Settings Logout
      `; attachLayoutListeners(); } export function attachLayoutListeners() { const searchInput = document.getElementById('search-input') as HTMLInputElement; searchInput?.addEventListener('input', (e) => { const query = (e.target as HTMLInputElement).value; router.updateQuery({ q: query }); }); const logoLink = document.getElementById('logo-link'); logoLink?.addEventListener('click', () => router.navigate('/')); document.getElementById('logout-button')?.addEventListener('click', (e) => { e.preventDefault(); logout(); }); document.getElementById('sidebar-toggle-btn')?.addEventListener('click', () => { store.toggleSidebar(); }); document.getElementById('sidebar-backdrop')?.addEventListener('click', () => { store.setSidebarVisible(false); }); window.addEventListener('resize', () => { if (window.innerWidth > 768 && !store.sidebarVisible) { store.setSidebarVisible(true); } }); // Collapsible sections document.querySelectorAll('.sidebar-section.collapsible h3').forEach(header => { header.addEventListener('click', () => { header.parentElement?.classList.toggle('collapsed'); }); }); // Event delegation for filters, tags, and feeds in sidebar const sidebar = document.getElementById('sidebar'); sidebar?.addEventListener('click', (e) => { const target = e.target as HTMLElement; const link = target.closest('a'); if (!link) { if (target.classList.contains('logo')) { e.preventDefault(); router.navigate('/', {}); } return; } const navType = link.getAttribute('data-nav'); const currentQuery = Object.fromEntries(router.getCurrentRoute().query.entries()); if (navType === 'filter') { e.preventDefault(); const filter = link.getAttribute('data-value') as FilterType; const currentRoute = router.getCurrentRoute(); if (currentRoute.path === '/settings') { router.navigate('/', { ...currentQuery, filter }); } else { router.updateQuery({ filter }); } } else if (navType === 'tag') { e.preventDefault(); const tag = link.getAttribute('data-value')!; router.navigate(`/tag/${encodeURIComponent(tag)}`, currentQuery); } else if (navType === 'feed') { e.preventDefault(); const feedId = link.getAttribute('data-value')!; if (store.activeFeedId === parseInt(feedId)) { router.navigate('/', currentQuery); } else { router.navigate(`/feed/${feedId}`, currentQuery); } } else if (navType === 'settings') { e.preventDefault(); const currentRoute = router.getCurrentRoute(); if (currentRoute.path === '/settings') { router.navigate('/', currentQuery); } else { router.navigate('/settings', currentQuery); } } // Auto-close sidebar on mobile after clicking a link if (window.innerWidth <= 768) { store.setSidebarVisible(false); } }); // Event delegation for content area (items) const contentArea = document.getElementById('content-area'); contentArea?.addEventListener('click', (e) => { const target = e.target as HTMLElement; // Handle Toggle Star const starBtn = target.closest('[data-action="toggle-star"]'); if (starBtn) { const itemRow = starBtn.closest('[data-id]'); if (itemRow) { const id = parseInt(itemRow.getAttribute('data-id')!); toggleStar(id); } return; } // Handle Scrape const scrapeBtn = target.closest('[data-action="scrape"]'); if (scrapeBtn) { const itemRow = scrapeBtn.closest('[data-id]'); if (itemRow) { const id = parseInt(itemRow.getAttribute('data-id')!); scrapeItem(id); } return; } // Handle Item interaction (mark as read on click title or row) const itemTitle = target.closest('[data-action="open"]'); const itemRow = target.closest('.feed-item'); if (itemRow && !itemTitle) { // Clicking the row itself (but not the link) const id = parseInt(itemRow.getAttribute('data-id')!); activeItemId = id; // Update visual selection document.querySelectorAll('.feed-item').forEach(el => { const itemId = parseInt(el.getAttribute('data-id') || '0'); el.classList.toggle('selected', itemId === activeItemId); }); const item = store.items.find(i => i._id === id); if (item && !item.read) { updateItem(id, { read: true }); } } }); } // --- Rendering Functions --- export function renderFeeds() { const { feeds, activeFeedId } = store; const feedListEl = document.getElementById('feed-list'); if (!feedListEl) return; feedListEl.innerHTML = feeds.map((feed: Feed) => `
    • ${feed.title || feed.url}
    • `).join(''); } export function renderTags() { const { tags, activeTagName } = store; const tagListEl = document.getElementById('tag-list'); if (!tagListEl) return; tagListEl.innerHTML = tags.map((tag: Category) => `
    • ${tag.title}
    • `).join(''); } export function renderFilters() { const { filter } = store; const filterListEl = document.getElementById('filter-list'); if (!filterListEl) return; filterListEl.querySelectorAll('li').forEach(el => { el.classList.toggle('active', el.getAttribute('data-filter') === filter); }); } export function renderItems() { const { items, loading } = store; if (itemObserver) { itemObserver.disconnect(); itemObserver = null; } const contentArea = document.getElementById('content-area'); if (!contentArea || router.getCurrentRoute().path === '/settings') return; if (loading && items.length === 0) { contentArea.innerHTML = '

      Loading items...

      '; return; } if (items.length === 0) { contentArea.innerHTML = '

      No items found.

      '; return; } contentArea.innerHTML = `
        ${items.map((item: Item) => createFeedItem(item, item._id === activeItemId)).join('')}
      ${store.hasMore ? '
      Loading more...
      ' : ''} `; // Setup infinite scroll const sentinel = document.getElementById('load-more-sentinel'); if (sentinel) { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !store.loading && store.hasMore) { loadMore(); } }, { threshold: 0.1 }); observer.observe(sentinel); } // Setup item observer for marking read itemObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const target = entry.target as HTMLElement; const id = parseInt(target.getAttribute('data-id') || '0'); if (id) { const item = store.items.find(i => i._id === id); if (item && !item.read) { updateItem(id, { read: true }); itemObserver?.unobserve(target); } } } }); }, { threshold: 1.0 }); contentArea.querySelectorAll('.feed-item').forEach(el => itemObserver!.observe(el)); } export function renderSettings() { const contentArea = document.getElementById('content-area'); if (!contentArea) return; contentArea.innerHTML = `

      Settings

      Add Feed

      Appearance

      Manage Feeds

        ${store.feeds.map(feed => `
      • ${feed.title || feed.url}
        ${feed.url}
      • `).join('')}

      Data Management

      `; // Attach settings listeners document.getElementById('theme-options')?.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('button'); if (btn) { const theme = btn.getAttribute('data-theme')!; store.setTheme(theme); renderSettings(); } }); document.getElementById('font-selector')?.addEventListener('change', (e) => { store.setFontTheme((e.target as HTMLSelectElement).value); }); document.getElementById('add-feed-btn')?.addEventListener('click', async () => { const input = document.getElementById('new-feed-url') as HTMLInputElement; const url = input.value.trim(); if (url) { const success = await addFeed(url); if (success) { input.value = ''; alert('Feed added successfully!'); fetchFeeds(); } else { alert('Failed to add feed.'); } } }); document.getElementById('export-opml-btn')?.addEventListener('click', () => { window.location.href = '/api/export/opml'; }); document.getElementById('import-opml-file')?.addEventListener('change', async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (file) { const success = await importOPML(file); if (success) { alert('OPML imported successfully! Crawling started.'); fetchFeeds(); } else { alert('Failed to import OPML.'); } } }); // Feed Management Listeners document.querySelectorAll('.delete-feed-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const id = parseInt((e.target as HTMLElement).getAttribute('data-id')!); if (confirm('Are you sure you want to delete this feed?')) { await deleteFeed(id); fetchFeeds(); // re-render settings to remove the deleted feed from list // delay slightly to allow feed fetch? No, fetchFeeds is async. // We should await fetchFeeds before re-rendering? // But fetchFeeds updates store, and store emits 'feeds-updated'. // Does 'feeds-updated' re-render settings? // No, 'feeds-updated' calls renderFeeds (the sidebar list). // So we need to explicitly call renderSettings() to update the management list. // But we should wait for fetchFeeds() to complete so store is updated. // wait... fetchFeeds() is async but we don't await result in the listener above? // Ah, fetchFeeds() returns Promise. await fetchFeeds(); renderSettings(); } }); }); document.querySelectorAll('.update-feed-tag-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const id = parseInt((e.target as HTMLElement).getAttribute('data-id')!); const input = document.querySelector(`.feed-tag-input[data-id="${id}"]`) as HTMLInputElement; const category = input.value.trim(); await updateFeed(id, { category }); // updateFeed returns boolean, assuming success await fetchFeeds(); await fetchTags(); renderSettings(); // Update list to show persistence alert('Feed updated'); }); }); } async function addFeed(url: string): Promise { try { const res = await apiFetch('/api/feed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); return res.ok; } catch (err) { console.error('Failed to add feed', err); return false; } } async function importOPML(file: File): Promise { try { const formData = new FormData(); formData.append('file', file); formData.append('format', 'opml'); // We need to handle CSRF manually since apiFetch expects JSON or simple body const csrfToken = document.cookie.split('; ').find(row => row.startsWith('csrf_token='))?.split('=')[1]; const res = await fetch('/api/import', { method: 'POST', headers: { 'X-CSRF-Token': csrfToken || '' }, body: formData }); return res.ok; } catch (err) { console.error('Failed to import OPML', err); return false; } } export async function deleteFeed(id: number): Promise { try { const res = await apiFetch(`/api/feed/${id}`, { method: 'DELETE' }); return res.ok; } catch (err) { console.error('Failed to delete feed', err); return false; } } export async function updateFeed(id: number, updates: Partial): Promise { try { const res = await apiFetch('/api/feed', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...updates, _id: id }) }); return res.ok; } catch (err) { console.error('Failed to update feed', err); return false; } } // --- Data Actions --- export async function toggleStar(id: number) { const item = store.items.find(i => i._id === id); if (item) { updateItem(id, { starred: !item.starred }); } } export async function scrapeItem(id: number) { const item = store.items.find(i => i._id === id); if (!item) return; try { const res = await apiFetch(`/api/item/${id}/content`); if (res.ok) { const data = await res.json(); if (data.full_content) { updateItem(id, { full_content: data.full_content }); } } } catch (err) { console.error('Failed to fetch full content', err); } } export async function updateItem(id: number, updates: Partial) { try { const res = await apiFetch(`/api/item/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates) }); if (res.ok) { const item = store.items.find(i => i._id === id); if (item) { Object.assign(item, updates); // Selective DOM update to avoid full re-render const el = document.querySelector(`.feed-item[data-id="${id}"]`); if (el) { if (updates.read !== undefined) el.classList.toggle('read', updates.read); if (updates.starred !== undefined) { const starBtn = el.querySelector('.star-btn'); if (starBtn) { starBtn.classList.toggle('is-starred', updates.starred); starBtn.classList.toggle('is-unstarred', !updates.starred); starBtn.setAttribute('title', updates.starred ? 'Unstar' : 'Star'); } } if (updates.full_content) { // If full content was scraped, we might need to update description or re-render chunk renderItems(); // Full re-render is safer for content injection } } } } } catch (err) { console.error('Failed to update item', err); } } export async function fetchFeeds() { const res = await apiFetch('/api/feed/'); if (res.ok) { const feeds = await res.json(); store.setFeeds(feeds); } } export async function fetchTags() { const res = await apiFetch('/api/tag'); if (res.ok) { const tags = await res.json(); store.setTags(tags); } } export async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { store.setLoading(true); try { const params = new URLSearchParams(); if (feedId) params.append('feed_id', feedId); if (tagName) params.append('tag', tagName); if (store.searchQuery) params.append('q', store.searchQuery); if (store.filter === 'starred' || store.filter === 'all') { params.append('read_filter', 'all'); } if (store.filter === 'starred') { params.append('starred', 'true'); } if (append && store.items.length > 0) { params.append('max_id', String(store.items[store.items.length - 1]._id)); } const res = await apiFetch(`/api/stream?${params.toString()}`); if (res.ok) { const items = await res.json(); store.setHasMore(items.length >= 50); store.setItems(items, append); } } finally { store.setLoading(false); } } export async function loadMore() { const route = router.getCurrentRoute(); fetchItems(route.params.feedId, route.params.tagName, true); } export async function logout() { await apiFetch('/api/logout', { method: 'POST' }); window.location.href = '/login/'; } // --- App Logic --- function handleRoute() { const route = router.getCurrentRoute(); const filterFromQuery = route.query.get('filter') as FilterType; store.setFilter(filterFromQuery || 'unread'); const qFromQuery = route.query.get('q'); if (qFromQuery !== null) { store.setSearchQuery(qFromQuery); } if (route.path === '/settings') { renderSettings(); return; } if (route.path === '/feed' && route.params.feedId) { const id = parseInt(route.params.feedId); store.setActiveFeed(id); fetchItems(route.params.feedId); document.getElementById('section-feeds')?.classList.remove('collapsed'); } else if (route.path === '/tag' && route.params.tagName) { store.setActiveTag(route.params.tagName); fetchItems(undefined, route.params.tagName); document.getElementById('section-tags')?.classList.remove('collapsed'); } else { store.setActiveFeed(null); store.setActiveTag(null); fetchItems(); } } // Keyboard shortcuts window.addEventListener('keydown', (e) => { if (['INPUT', 'TEXTAREA'].includes((e.target as any).tagName)) return; switch (e.key) { case 'j': navigateItems(1); break; case 'k': navigateItems(-1); break; case 'r': if (activeItemId) { const item = store.items.find(i => i._id === activeItemId); if (item) updateItem(item._id, { read: !item.read }); } break; case 's': if (activeItemId) { const item = store.items.find(i => i._id === activeItemId); if (item) updateItem(item._id, { starred: !item.starred }); } break; case '/': e.preventDefault(); document.getElementById('search-input')?.focus(); break; } }); function navigateItems(direction: number) { if (store.items.length === 0) return; const currentIndex = store.items.findIndex(i => i._id === activeItemId); let nextIndex; if (currentIndex === -1) { nextIndex = direction > 0 ? 0 : store.items.length - 1; } else { nextIndex = currentIndex + direction; } if (nextIndex >= 0 && nextIndex < store.items.length) { activeItemId = store.items[nextIndex]._id; // Update visual selection without full re-render for speed document.querySelectorAll('.feed-item').forEach(el => { const id = parseInt(el.getAttribute('data-id') || '0'); el.classList.toggle('selected', id === activeItemId); }); const el = document.querySelector(`.feed-item[data-id="${activeItemId}"]`); if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' }); if (!store.items[nextIndex].read) updateItem(activeItemId, { read: true }); } } // Subscribe to store store.on('feeds-updated', renderFeeds); store.on('tags-updated', renderTags); store.on('active-feed-updated', renderFeeds); store.on('active-tag-updated', renderTags); store.on('filter-updated', renderFilters); store.on('search-updated', () => { const searchInput = document.getElementById('search-input') as HTMLInputElement; if (searchInput && searchInput.value !== store.searchQuery) { searchInput.value = store.searchQuery; } handleRoute(); }); store.on('theme-updated', () => { if (!appEl) appEl = document.querySelector('#app'); if (appEl) { appEl.className = `theme-${store.theme} font-${store.fontTheme}`; } }); store.on('sidebar-toggle', () => { const layout = document.querySelector('.layout'); if (layout) { if (store.sidebarVisible) { layout.classList.remove('sidebar-hidden'); layout.classList.add('sidebar-visible'); } else { layout.classList.remove('sidebar-visible'); layout.classList.add('sidebar-hidden'); } } }); store.on('items-updated', renderItems); store.on('loading-state-changed', renderItems); // Subscribe to router router.addEventListener('route-changed', handleRoute); // Compatibility app object (empty handlers, since we use delegation) window.app = { navigate: (path: string) => router.navigate(path) }; // Start export async function init() { const authRes = await apiFetch('/api/auth'); if (!authRes || authRes.status === 401) { window.location.href = '/login/'; return; } renderLayout(); renderFilters(); try { await Promise.all([fetchFeeds(), fetchTags()]); } catch (err) { console.error('Initial fetch failed', err); } handleRoute(); } // Only auto-init if not in a test environment if (typeof window !== 'undefined' && !(window as any).__VITEST__) { init(); }