diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/src/components/FeedItems.css | 12 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.test.tsx | 55 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 46 |
3 files changed, 106 insertions, 7 deletions
diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css index f271b34..54c882a 100644 --- a/frontend/src/components/FeedItems.css +++ b/frontend/src/components/FeedItems.css @@ -12,10 +12,20 @@ list-style: none; padding: 0; } + .selected-item-container { border-left: 4px solid #007bff; background-color: #f8f9fa; padding-left: 0.5rem; - margin-left: -0.5rem; /* Compensate for padding/border to keep alignment */ + margin-left: -0.5rem; + /* Compensate for padding/border to keep alignment */ transition: background-color 0.2s; } + +.loading-more { + padding: 2rem; + text-align: center; + color: #888; + font-size: 0.9rem; + min-height: 50px; +}
\ No newline at end of file diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx index 00118fa..ea68a7c 100644 --- a/frontend/src/components/FeedItems.test.tsx +++ b/frontend/src/components/FeedItems.test.tsx @@ -53,8 +53,6 @@ describe('FeedItems Component', () => { await waitFor(() => { expect(screen.getByText('Item One')).toBeInTheDocument(); - // Title should now be "Feed Items" based on logic - expect(screen.getByText('Feed Items')).toBeInTheDocument(); }); const params = new URLSearchParams(); @@ -171,4 +169,57 @@ describe('FeedItems Component', () => { })); }); }); + + it('loads more items when sentinel becomes visible', async () => { + const initialItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: true, starred: false }]; + const moreItems = [{ _id: 100, title: 'Item 0', url: 'u0', read: true, starred: false }]; + + (global.fetch as any) + .mockResolvedValueOnce({ ok: true, json: async () => initialItems }) + .mockResolvedValueOnce({ ok: true, json: async () => moreItems }); + + let observerCallback: IntersectionObserverCallback = () => { }; + class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + window.IntersectionObserver = MockIntersectionObserver as any; + + render( + <MemoryRouter> + <FeedItems /> + </MemoryRouter> + ); + + await waitFor(() => { + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + // Simulate sentinel becoming visible + const entry = { + isIntersecting: true, + target: { id: 'load-more-sentinel' } as unknown as Element, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: 1, + time: 0, + rootBounds: null, + intersectionRect: {} as DOMRectReadOnly, + } as IntersectionObserverEntry; + + act(() => { + observerCallback([entry], {} as IntersectionObserver); + }); + + await waitFor(() => { + expect(screen.getByText('Item 0')).toBeInTheDocument(); + const params = new URLSearchParams(); + params.append('max_id', '101'); + params.append('read_filter', 'unread'); + expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`); + }); + }); }); diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index aaec2f4..179d5cd 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -11,10 +11,17 @@ export default function FeedItems() { const [items, setItems] = useState<Item[]>([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(''); - useEffect(() => { - setLoading(true); + const fetchItems = (maxId?: string) => { + if (maxId) { + setLoadingMore(true); + } else { + setLoading(true); + setItems([]); + } setError(''); let url = '/api/stream'; @@ -26,6 +33,10 @@ export default function FeedItems() { params.append('tag', tagName); } + if (maxId) { + params.append('max_id', maxId); + } + // Apply filters if (filterFn === 'all') { params.append('read_filter', 'all'); @@ -50,13 +61,24 @@ export default function FeedItems() { return res.json(); }) .then((data) => { - setItems(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(); }, [feedId, tagName, filterFn]); const [selectedIndex, setSelectedIndex] = useState(-1); @@ -134,6 +156,14 @@ export default function FeedItems() { 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')); @@ -154,8 +184,11 @@ export default function FeedItems() { if (el) observer.observe(el); }); + const sentinel = document.getElementById('load-more-sentinel'); + if (sentinel) observer.observe(sentinel); + return () => observer.disconnect(); - }, [items]); + }, [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>; @@ -178,6 +211,11 @@ export default function FeedItems() { <FeedItem item={item} /> </div> ))} + {hasMore && ( + <div id="load-more-sentinel" className="loading-more"> + {loadingMore ? 'Loading more...' : ''} + </div> + )} </ul> )} </div> |
