diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-13 10:04:17 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-13 10:04:17 -0800 |
| commit | 5669961d674b2764082c7c9585484cb090b71e45 (patch) | |
| tree | 12cb5fadb849ecb725e7484e3717b9740800e929 /frontend/src/components/FeedItems.tsx | |
| parent | 23a48e1d498680be769e931f46ddb1fd44f38d1a (diff) | |
| download | neko-5669961d674b2764082c7c9585484cb090b71e45.tar.gz neko-5669961d674b2764082c7c9585484cb090b71e45.tar.bz2 neko-5669961d674b2764082c7c9585484cb090b71e45.zip | |
Implement frontend parity features: Unread view, shortcuts, scroll-to-read, filters
Diffstat (limited to 'frontend/src/components/FeedItems.tsx')
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 148 |
1 files changed, 141 insertions, 7 deletions
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index 01a24fc..9e732a0 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +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<Item[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -15,10 +18,28 @@ export default function FeedItems() { setError(''); let url = '/api/stream'; + const params = new URLSearchParams(); + if (feedId) { - url = `/api/stream?feed_id=${feedId}`; + params.append('feed_id', feedId); } else if (tagName) { - url = `/api/stream?tag=${encodeURIComponent(tagName)}`; + params.append('tag', tagName); + } + + // Apply filters + 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 + params.append('read_filter', 'unread'); + } + + const queryString = params.toString(); + if (queryString) { + url += `?${queryString}`; } fetch(url) @@ -36,20 +57,133 @@ export default function FeedItems() { setError(err.message); setLoading(false); }); - }, [feedId, tagName]); + }, [feedId, tagName, filterFn]); + + 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; + }); + } + }; + + 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: 'smooth', 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 observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + // 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); + }); + + return () => observer.disconnect(); + }, [items]); if (loading) return <div className="feed-items-loading">Loading items...</div>; if (error) return <div className="feed-items-error">Error: {error}</div>; + let title = 'Items'; + if (tagName) title = `Tag: ${tagName}`; + else if (feedId) title = 'Feed Items'; + else if (filterFn === 'starred') title = 'Starred Items'; + else if (filterFn === 'all') title = 'All Items'; + else title = 'Unread Items'; + return ( <div className="feed-items"> - <h2>{tagName ? `Tag: ${tagName}` : 'Items'}</h2> + <h2>{title}</h2> {items.length === 0 ? ( <p>No items found.</p> ) : ( <ul className="item-list"> - {items.map((item) => ( - <FeedItem key={item._id} item={item} /> + {items.map((item, index) => ( + <div + id={`item-${index}`} + key={item._id} + data-index={index} + className={index === selectedIndex ? 'selected-item-container' : ''} + onClick={() => setSelectedIndex(index)} + > + <FeedItem item={item} /> + </div> ))} </ul> )} |
