import { useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import type { Item } from '../types'; import FeedItem from './FeedItem'; import './FeedItems.css'; export default function FeedItems() { const { feedId, tagName } = useParams<{ feedId: string; tagName: string }>(); const [searchParams] = useSearchParams(); const filterFn = searchParams.get('filter') || 'unread'; const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(''); const [selectedIndex, setSelectedIndex] = useState(-1); const fetchItems = (maxId?: string) => { if (maxId) { setLoadingMore(true); } else { setLoading(true); setItems([]); } setError(''); let url = '/api/stream'; const params = new URLSearchParams(); if (feedId) { params.append('feed_id', feedId); } else if (tagName) { params.append('tag', tagName); } if (maxId) { params.append('max_id', maxId); } // Apply filters const searchQuery = searchParams.get('q'); if (searchQuery) { params.append('q', searchQuery); } if (filterFn === 'all') { params.append('read_filter', 'all'); } else if (filterFn === 'starred') { params.append('starred', 'true'); params.append('read_filter', 'all'); } else { // default to unread if (!searchQuery) { params.append('read_filter', 'unread'); } } const queryString = params.toString(); if (queryString) { url += `?${queryString}`; } fetch(url) .then((res) => { if (!res.ok) { throw new Error('Failed to fetch items'); } return res.json(); }) .then((data) => { if (maxId) { setItems((prev) => [...prev, ...data]); } else { setItems(data); } setHasMore(data.length > 0); setLoading(false); setLoadingMore(false); }) .catch((err) => { setError(err.message); setLoading(false); setLoadingMore(false); }); }; useEffect(() => { fetchItems(); setSelectedIndex(-1); }, [feedId, tagName, filterFn, searchParams]); const scrollToItem = (index: number) => { const element = document.getElementById(`item-${index}`); if (element) { element.scrollIntoView({ behavior: 'auto', block: 'start' }); } }; const markAsRead = (item: Item) => { const updatedItem = { ...item, read: true }; // Optimistic update setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i))); fetch(`/api/item/${item._id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ read: true, starred: item.starred }), }).catch((err) => console.error('Failed to mark read', err)); }; const toggleStar = (item: Item) => { const updatedItem = { ...item, starred: !item.starred }; // Optimistic update setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i))); fetch(`/api/item/${item._id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ read: item.read, starred: !item.starred }), }).catch((err) => console.error('Failed to toggle star', err)); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (items.length === 0) return; if (e.key === 'j') { setSelectedIndex((prev) => { const nextIndex = Math.min(prev + 1, items.length - 1); if (nextIndex !== prev) { const item = items[nextIndex]; if (!item.read) { markAsRead(item); } scrollToItem(nextIndex); } return nextIndex; }); } else if (e.key === 'k') { setSelectedIndex((prev) => { const nextIndex = Math.max(prev - 1, 0); if (nextIndex !== prev) { scrollToItem(nextIndex); } return nextIndex; }); } else if (e.key === 's') { setSelectedIndex((currentIndex) => { if (currentIndex >= 0 && currentIndex < items.length) { toggleStar(items[currentIndex]); } return currentIndex; }); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [items]); useEffect(() => { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { // Infinity scroll sentinel if (entry.target.id === 'load-more-sentinel') { if (entry.isIntersecting && !loadingMore && hasMore && items.length > 0) { fetchItems(String(items[items.length - 1]._id)); } return; } // If item is not intersecting and is above the viewport, it's been scrolled past if (!entry.isIntersecting && entry.boundingClientRect.top < 0) { const index = Number(entry.target.getAttribute('data-index')); if (!isNaN(index) && index >= 0 && index < items.length) { const item = items[index]; if (!item.read) { markAsRead(item); } } } }); }, { root: null, threshold: 0 } ); items.forEach((_, index) => { const el = document.getElementById(`item-${index}`); if (el) observer.observe(el); }); const sentinel = document.getElementById('load-more-sentinel'); if (sentinel) observer.observe(sentinel); return () => observer.disconnect(); }, [items, loadingMore, hasMore]); if (loading) return
Loading items...
; if (error) return
Error: {error}
; return (
{items.length === 0 ? (

No items found.

) : ( )}
); }