From 5669961d674b2764082c7c9585484cb090b71e45 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Fri, 13 Feb 2026 10:04:17 -0800 Subject: Implement frontend parity features: Unread view, shortcuts, scroll-to-read, filters --- frontend/src/App.tsx | 2 +- frontend/src/components/FeedItems.css | 9 +- frontend/src/components/FeedItems.test.tsx | 128 ++++++++++++++++++++++++- frontend/src/components/FeedItems.tsx | 148 +++++++++++++++++++++++++++-- frontend/src/components/FeedList.css | 24 +++++ frontend/src/components/FeedList.tsx | 7 ++ frontend/src/components/TagView.test.tsx | 5 +- 7 files changed, 310 insertions(+), 13 deletions(-) (limited to 'frontend/src') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 09148d6..2758472 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,7 +61,7 @@ function Dashboard() { } /> } /> } /> - Select a feed to view items.

} /> + } /> diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css index 795156d..f271b34 100644 --- a/frontend/src/components/FeedItems.css +++ b/frontend/src/components/FeedItems.css @@ -11,4 +11,11 @@ .item-list { list-style: none; padding: 0; -} \ No newline at end of file +} +.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 */ + transition: background-color 0.2s; +} diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx index 9c271c4..00118fa 100644 --- a/frontend/src/components/FeedItems.test.tsx +++ b/frontend/src/components/FeedItems.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import FeedItems from './FeedItems'; @@ -9,6 +9,15 @@ describe('FeedItems Component', () => { beforeEach(() => { vi.resetAllMocks(); global.fetch = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + // Mock IntersectionObserver + class MockIntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + window.IntersectionObserver = MockIntersectionObserver as any; }); it('renders loading state', () => { @@ -44,9 +53,122 @@ describe('FeedItems Component', () => { await waitFor(() => { expect(screen.getByText('Item One')).toBeInTheDocument(); - expect(screen.getByText('Item Two')).toBeInTheDocument(); + // Title should now be "Feed Items" based on logic + expect(screen.getByText('Feed Items')).toBeInTheDocument(); + }); + + const params = new URLSearchParams(); + params.append('feed_id', '1'); + params.append('read_filter', 'unread'); + expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`); + }); + + it('handles keyboard shortcuts', async () => { + const mockItems = [ + { _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }, + { _id: 102, title: 'Item 2', url: 'u2', read: true, starred: false }, + ]; + + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockItems, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Item 1')).toBeVisible(); + }); + + // Press 'j' to select first item (index 0 -> 1 because it starts at -1... wait logic says min(prev+1)) + // init -1. j -> 0. + fireEvent.keyDown(window, { key: 'j' }); + + // Item 1 (index 0) should be selected. + // It's unread, so it should be marked read. + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith('/api/item/101', expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ read: true, starred: false }), + })); + }); + + // Press 'j' again -> index 1 (Item 2) + fireEvent.keyDown(window, { key: 'j' }); + + // Item 2 is already read, so no markRead call expected for it (mocks clear? no). + // let's check selection class if possible, but testing library doesn't easily check class on div wrapper unless we query it. + + // Press 's' to star Item 2 + fireEvent.keyDown(window, { key: 's' }); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith('/api/item/102', expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ read: true, starred: true }), // toggled to true + })); + }); + }); + + it('marks items as read when scrolled past', async () => { + const mockItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }]; + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => mockItems, }); - expect(global.fetch).toHaveBeenCalledWith('/api/stream?feed_id=1'); + // Capture the callback + let observerCallback: IntersectionObserverCallback = () => { }; + + // Override the mock to capture callback + class MockIntersectionObserver { + constructor(callback: IntersectionObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + } + window.IntersectionObserver = MockIntersectionObserver as any; + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Item 1')).toBeVisible(); + }); + + // Simulate item leaving viewport at the top + // Element index is 0 + const entry = { + isIntersecting: false, + boundingClientRect: { top: -50 } as DOMRectReadOnly, + target: { getAttribute: () => '0' } as unknown as Element, + intersectionRatio: 0, + time: 0, + rootBounds: null, + intersectionRect: {} as DOMRectReadOnly, + } as IntersectionObserverEntry; + + // Use vi.waitUntil to wait for callback to be assigned if needed, + // though strictly synchronous render + effect should do it. + // Direct call: + act(() => { + observerCallback([entry], {} as IntersectionObserver); + }); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith('/api/item/101', expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ read: true, starred: false }), + })); + }); }); }); 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([]); 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
Loading items...
; if (error) return
Error: {error}
; + 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 (
-

{tagName ? `Tag: ${tagName}` : 'Items'}

+

{title}

{items.length === 0 ? (

No items found.

) : (
    - {items.map((item) => ( - + {items.map((item, index) => ( +
    setSelectedIndex(index)} + > + +
    ))}
)} diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css index 485fab3..b8ca7e6 100644 --- a/frontend/src/components/FeedList.css +++ b/frontend/src/components/FeedList.css @@ -80,3 +80,27 @@ background: #dde2e6; color: #000; } + +.filter-section { + padding-bottom: 1rem; + border-bottom: 1px solid #eee; + margin-bottom: 1rem; +} + +.filter-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + gap: 1rem; +} + +.filter-list li a { + text-decoration: none; + color: #333; + font-weight: 500; +} + +.filter-list li a:hover { + color: #007bff; +} diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index f17fdc7..d1a4625 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -36,6 +36,13 @@ export default function FeedList() { return (
+
+
    +
  • Unread
  • +
  • All
  • +
  • Starred
  • +
+

Feeds

{feeds.length === 0 ? ( diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx index 8a724cd..6304fb2 100644 --- a/frontend/src/components/TagView.test.tsx +++ b/frontend/src/components/TagView.test.tsx @@ -76,6 +76,9 @@ describe('Tag View Integration', () => { expect(screen.getByText('Tag Item 1')).toBeInTheDocument(); }); - expect(global.fetch).toHaveBeenCalledWith('/api/stream?tag=Tech'); + const params = new URLSearchParams(); + params.append('tag', 'Tech'); + params.append('read_filter', 'unread'); + expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`); }); }); -- cgit v1.2.3