diff options
Diffstat (limited to 'frontend/src/components/FeedItems.tsx')
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 423 |
1 files changed, 212 insertions, 211 deletions
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index bcee3b0..81c9139 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -5,227 +5,228 @@ 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<Item[]>([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); - const [error, setError] = useState(''); - - const fetchItems = (maxId?: string) => { - if (maxId) { - setLoadingMore(true); - } else { - setLoading(true); - setItems([]); + const { feedId, tagName } = useParams<{ feedId: string; tagName: string }>(); + const [searchParams] = useSearchParams(); + const filterFn = searchParams.get('filter') || 'unread'; + + const [items, setItems] = useState<Item[]>([]); + 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'); } - setError(''); - - let url = '/api/stream'; - const params = new URLSearchParams(); - - if (feedId) { - params.append('feed_id', feedId); - } else if (tagName) { - params.append('tag', tagName); - } - + return res.json(); + }) + .then((data) => { 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'); + setItems((prev) => [...prev, ...data]); } else { - // default to unread - if (!searchQuery) { - params.append('read_filter', 'unread'); - } - } - - const queryString = params.toString(); - if (queryString) { - url += `?${queryString}`; + setItems(data); } - - 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 [selectedIndex, setSelectedIndex] = useState(-1); - - 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; - }); + 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); } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [items]); - - const scrollToItem = (index: number) => { - const element = document.getElementById(`item-${index}`); - if (element) { - element.scrollIntoView({ behavior: 'auto', block: 'start' }); - } + 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; + }); + } }; - const markAsRead = (item: Item) => { - const updatedItem = { ...item, read: true }; - // Optimistic update - setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i))); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [items]); - 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 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); + 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); + } + } + } }); - - const sentinel = document.getElementById('load-more-sentinel'); - if (sentinel) observer.observe(sentinel); - - return () => observer.disconnect(); - }, [items, loadingMore, hasMore]); - - if (loading) return <div className="feed-items-loading">Loading items...</div>; - if (error) return <div className="feed-items-error">Error: {error}</div>; - - - return ( - <div className="feed-items"> - {items.length === 0 ? ( - <p>No items found.</p> - ) : ( - <ul className="item-list"> - {items.map((item, index) => ( - <div - id={`item-${index}`} - key={item._id} - data-index={index} - data-selected={index === selectedIndex} - onClick={() => setSelectedIndex(index)} - > - <FeedItem item={item} /> - </div> - ))} - {hasMore && ( - <div id="load-more-sentinel" className="loading-more"> - {loadingMore ? 'Loading more...' : ''} - </div> - )} - </ul> - )} - </div> + }, + { 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 <div className="feed-items-loading">Loading items...</div>; + if (error) return <div className="feed-items-error">Error: {error}</div>; + + return ( + <div className="feed-items"> + {items.length === 0 ? ( + <p>No items found.</p> + ) : ( + <ul className="item-list"> + {items.map((item, index) => ( + <div + id={`item-${index}`} + key={item._id} + data-index={index} + data-selected={index === selectedIndex} + onClick={() => setSelectedIndex(index)} + > + <FeedItem item={item} /> + </div> + ))} + {hasMore && ( + <div id="load-more-sentinel" className="loading-more"> + {loadingMore ? 'Loading more...' : ''} + </div> + )} + </ul> + )} + </div> + ); } |
