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'; // Cache elements const appEl = document.querySelector('#app')!; // Initial Layout appEl.innerHTML = `

All Items

Select an item to read
`; const feedListEl = document.getElementById('feed-list')!; const tagListEl = document.getElementById('tag-list')!; const filterListEl = document.getElementById('filter-list')!; const viewTitleEl = document.getElementById('view-title')!; const itemListEl = document.getElementById('item-list-container')!; const itemDetailEl = document.getElementById('item-detail-content')!; // --- Rendering Functions --- function renderFeeds() { const { feeds, activeFeedId } = store; feedListEl.innerHTML = feeds.map((feed: Feed) => createFeedItem(feed, feed._id === activeFeedId) ).join(''); } function renderTags() { const { tags, activeTagName } = store; tagListEl.innerHTML = tags.map((tag: Category) => `
  • ${tag.title}
  • `).join(''); } function renderFilters() { const { filter } = store; filterListEl.querySelectorAll('.filter-item').forEach(el => { el.classList.toggle('active', el.getAttribute('data-filter') === filter); }); } function renderItems() { const { items, loading } = store; if (loading && items.length === 0) { itemListEl.innerHTML = '

    Loading items...

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

    No items found.

    '; return; } itemListEl.innerHTML = ` ${store.hasMore ? '
    Loading more...
    ' : ''} `; // Add click listeners to items itemListEl.querySelectorAll('.item-row').forEach(row => { row.addEventListener('click', () => { const id = parseInt(row.getAttribute('data-id') || '0'); selectItem(id); }); }); // Infinite scroll observer const loadMoreEl = document.getElementById('load-more'); if (loadMoreEl) { const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !store.loading && store.hasMore) { loadMore(); } }, { threshold: 0.1 }); observer.observe(loadMoreEl); } } async function selectItem(id: number) { const item = store.items.find((i: Item) => i._id === id); if (!item) return; // Mark active row itemListEl.querySelectorAll('.item-row').forEach(row => { row.classList.toggle('active', parseInt(row.getAttribute('data-id') || '0') === id); }); // Render basic detail itemDetailEl.innerHTML = `

    ${item.title}

    From ${item.feed_title || 'Unknown'} on ${new Date(item.publish_date).toLocaleString()}
    ${item.description || 'No description available.'}
    `; // Mark as read if not already if (!item.read) { try { await apiFetch(`/api/item/${item._id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ read: true }) }); item.read = true; const row = itemListEl.querySelector(`.item-row[data-id="${id}"]`); if (row) row.classList.add('read'); } catch (err) { console.error('Failed to mark as read', err); } } // Fetch full content if missing if (item.url && (!item.full_content || item.full_content === item.description)) { try { const res = await apiFetch(`/api/item/${item._id}/content`); if (res.ok) { const data = await res.json(); if (data.full_content) { item.full_content = data.full_content; const contentEl = document.getElementById('full-content'); if (contentEl) contentEl.innerHTML = data.full_content; } } } catch (err) { console.error('Failed to fetch full content', err); } } } // --- Data Actions --- async function fetchFeeds() { try { const res = await apiFetch('/api/feed/'); if (!res.ok) throw new Error('Failed to fetch feeds'); const feeds = await res.json(); store.setFeeds(feeds); } catch (err) { console.error(err); } } async function fetchTags() { try { const res = await apiFetch('/api/tag'); if (!res.ok) throw new Error('Failed to fetch tags'); const tags = await res.json(); store.setTags(tags); } catch (err) { console.error(err); } } async function fetchItems(feedId?: string, tagName?: string, append: boolean = false) { store.setLoading(true); try { let url = '/api/stream'; const params = new URLSearchParams(); if (feedId) params.append('feed_id', feedId); if (tagName) params.append('tag', tagName); // Add filter logic if (store.filter === 'unread') params.append('read', 'false'); 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(`${url}?${params.toString()}`); if (!res.ok) throw new Error('Failed to fetch items'); const items = await res.json(); store.setHasMore(items.length >= 50); // backend default page size is 50 store.setItems(items, append); if (!append) { itemDetailEl.innerHTML = '
    Select an item to read
    '; } } catch (err) { console.error(err); if (!append) store.setItems([]); } finally { store.setLoading(false); } } async function loadMore() { const route = router.getCurrentRoute(); fetchItems(route.params.feedId, route.params.tagName, true); } // --- App Logic --- function handleRoute() { const route = router.getCurrentRoute(); // Update filter from query if present const filterFromQuery = route.query.get('filter') as FilterType; if (filterFromQuery && ['unread', 'all', 'starred'].includes(filterFromQuery)) { store.setFilter(filterFromQuery); } else { // Default to unread if not specified in URL and not already set // But actually, we want the URL to be the source of truth if possible. } if (route.path === '/feed' && route.params.feedId) { const id = parseInt(route.params.feedId); store.setActiveFeed(id); const feed = store.feeds.find((f: Feed) => f._id === id); viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`; fetchItems(route.params.feedId); } else if (route.path === '/tag' && route.params.tagName) { store.setActiveTag(route.params.tagName); viewTitleEl.textContent = `Tag: ${route.params.tagName}`; fetchItems(undefined, route.params.tagName); } else { store.setActiveFeed(null); store.setActiveTag(null); viewTitleEl.textContent = 'All Items'; fetchItems(); } } // 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(); handleRoute(); }); store.on('items-updated', renderItems); store.on('loading-state-changed', renderItems); // Subscribe to router router.addEventListener('route-changed', handleRoute); // Global app object for inline handlers (window as any).app = { navigate: (path: string) => router.navigate(path), setFilter: (filter: FilterType) => router.updateQuery({ filter }) }; // Start async function init() { const authRes = await apiFetch('/api/auth'); if (authRes.status === 401) { window.location.href = '/login/'; return; } renderFilters(); await Promise.all([fetchFeeds(), fetchTags()]); handleRoute(); // handles initial route } init();