From 50d01525ac9f67c5a3e680a3f807c204f6a1cdbd Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Sun, 15 Feb 2026 18:01:57 -0800 Subject: Vanilla JS (v3): Implement Tags, Filters, and Infinite Scroll --- frontend-vanilla/src/main.ts | 119 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 11 deletions(-) (limited to 'frontend-vanilla/src/main.ts') diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 6846a67..4012386 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -1,8 +1,9 @@ import './style.css'; import { apiFetch } from './api'; import { store } from './store'; +import type { FilterType } from './store'; import { router } from './router'; -import type { Feed, Item } from './types'; +import type { Feed, Item, Category } from './types'; import { createFeedItem } from './components/FeedItem'; // Cache elements @@ -13,9 +14,26 @@ appEl.innerHTML = `
@@ -32,6 +50,8 @@ appEl.innerHTML = ` `; 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')!; @@ -45,10 +65,28 @@ function renderFeeds() { ).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) { + if (loading && items.length === 0) { itemListEl.innerHTML = '

    Loading items...

    '; return; } @@ -67,6 +105,7 @@ function renderItems() { `).join('')} + ${store.hasMore ? '
    Loading more...
    ' : ''} `; // Add click listeners to items @@ -76,6 +115,17 @@ function renderItems() { 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) { @@ -149,7 +199,18 @@ async function fetchFeeds() { } } -async function fetchItems(feedId?: string, tagName?: string) { +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'; @@ -157,24 +218,51 @@ async function fetchItems(feedId?: string, tagName?: string) { 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.setItems(items); - itemDetailEl.innerHTML = '
    Select an item to read
    '; + + 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); - store.setItems([]); + 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); @@ -182,11 +270,12 @@ function handleRoute() { viewTitleEl.textContent = feed ? feed.title : `Feed ${id}`; fetchItems(route.params.feedId); } else if (route.path === '/tag' && route.params.tagName) { - store.setActiveFeed(null); + 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(); } @@ -194,7 +283,13 @@ function handleRoute() { // 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); @@ -203,7 +298,8 @@ router.addEventListener('route-changed', handleRoute); // Global app object for inline handlers (window as any).app = { - navigate: (path: string) => router.navigate(path) + navigate: (path: string) => router.navigate(path), + setFilter: (filter: FilterType) => router.updateQuery({ filter }) }; // Start @@ -214,7 +310,8 @@ async function init() { return; } - await fetchFeeds(); + renderFilters(); + await Promise.all([fetchFeeds(), fetchTags()]); handleRoute(); // handles initial route } -- cgit v1.2.3