diff options
Diffstat (limited to 'frontend/src')
23 files changed, 1484 insertions, 1386 deletions
diff --git a/frontend/src/App.css b/frontend/src/App.css index 3463f5d..09d1408 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,8 +5,6 @@ body { margin: 0; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; } /* Dashboard Layout */ @@ -89,7 +87,7 @@ body { } .dashboard-main>* { - max-width: 600px; + max-width: 35em; margin: 0; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 303ac7e..196f32a 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -5,52 +5,55 @@ import App from './App'; import { describe, it, expect, vi, beforeEach } from 'vitest'; describe('App', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + it('renders login on initial load (unauthenticated)', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, }); - - it('renders login on initial load (unauthenticated)', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: false, - }); - window.history.pushState({}, 'Test page', '/v2/login'); - render(<App />); - expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + window.history.pushState({}, 'Test page', '/v2/login'); + render(<App />); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('renders dashboard when authenticated', async () => { + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/auth')) return Promise.resolve({ ok: true }); + if (url.includes('/api/feed/')) return Promise.resolve({ ok: true, json: async () => [] }); + if (url.includes('/api/tag')) return Promise.resolve({ ok: true, json: async () => [] }); + return Promise.resolve({ ok: true }); // Fallback }); - it('renders dashboard when authenticated', async () => { - (global.fetch as any).mockImplementation((url: string) => { - if (url.includes('/api/auth')) return Promise.resolve({ ok: true }); - if (url.includes('/api/feed/')) return Promise.resolve({ ok: true, json: async () => [] }); - if (url.includes('/api/tag')) return Promise.resolve({ ok: true, json: async () => [] }); - return Promise.resolve({ ok: true }); // Fallback - }); - - window.history.pushState({}, 'Test page', '/v2/'); - render(<App />); + window.history.pushState({}, 'Test page', '/v2/'); + render(<App />); - await waitFor(() => { - expect(screen.getByText('🐱')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('🐱')).toBeInTheDocument(); + }); - // Test Logout - const logoutBtn = screen.getByText(/logout/i); - expect(logoutBtn).toBeInTheDocument(); + // Test Logout + const logoutBtn = screen.getByText(/logout/i); + expect(logoutBtn).toBeInTheDocument(); - // Mock window.location - Object.defineProperty(window, 'location', { - configurable: true, - value: { href: '' }, - }); + // Mock window.location + Object.defineProperty(window, 'location', { + configurable: true, + value: { href: '' }, + }); - (global.fetch as any).mockResolvedValueOnce({ ok: true }); + (global.fetch as any).mockResolvedValueOnce({ ok: true }); - fireEvent.click(logoutBtn); + fireEvent.click(logoutBtn); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith('/api/logout', expect.objectContaining({ method: 'POST' })); - expect(window.location.href).toBe('/v2/login'); - }); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/logout', + expect.objectContaining({ method: 'POST' }) + ); + expect(window.location.href).toBe('/v2/login'); }); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f53ace..4835cd3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,21 +35,47 @@ import FeedList from './components/FeedList'; import FeedItems from './components/FeedItems'; import Settings from './components/Settings'; -function Dashboard({ theme, setTheme }: { theme: string, setTheme: (t: string) => void }) { +function Dashboard({ theme, setTheme }: { theme: string; setTheme: (t: string) => void }) { const navigate = useNavigate(); const [sidebarVisible, setSidebarVisible] = useState(true); return ( - <div className={`dashboard ${sidebarVisible ? 'sidebar-visible' : 'sidebar-hidden'} theme-${theme}`}> + <div + className={`dashboard ${sidebarVisible ? 'sidebar-visible' : 'sidebar-hidden'} theme-${theme}`} + > <header className="dashboard-header"> - <h1 className="logo" onClick={() => setSidebarVisible(!sidebarVisible)} style={{ cursor: 'pointer' }}>🐱</h1> + <h1 + className="logo" + onClick={() => setSidebarVisible(!sidebarVisible)} + style={{ cursor: 'pointer' }} + > + 🐱 + </h1> <nav> - <button onClick={() => navigate('/settings')} className="nav-link" style={{ color: 'white', marginRight: '1rem', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit', fontFamily: 'inherit' }}>Settings</button> + <button + onClick={() => navigate('/settings')} + className="nav-link" + style={{ + color: 'white', + marginRight: '1rem', + background: 'none', + border: 'none', + cursor: 'pointer', + fontSize: 'inherit', + fontFamily: 'inherit', + }} + > + Settings + </button> - <button onClick={() => { - fetch('/api/logout', { method: 'POST' }) - .then(() => window.location.href = '/v2/login'); - }} className="logout-btn"> + <button + onClick={() => { + fetch('/api/logout', { method: 'POST' }).then( + () => (window.location.href = '/v2/login') + ); + }} + className="logout-btn" + > Logout </button> </nav> diff --git a/frontend/src/components/FeedItem.css b/frontend/src/components/FeedItem.css index 1261737..1736032 100644 --- a/frontend/src/components/FeedItem.css +++ b/frontend/src/components/FeedItem.css @@ -1,114 +1,108 @@ .feed-item { - padding: 1rem; - margin-top: 5rem; - list-style: none; - border-bottom: none; + padding: 1rem; + margin-top: 5rem; + list-style: none; + border-bottom: none; } -.feed-item.read .item-title { - font-weight: normal; -} - -.feed-item.unread .item-title { - font-weight: bold; -} +/* removed read/unread specific font-weight to keep it always bold as requested */ .item-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; } .item-title { - font-size: 1.35rem; - /* approx 24px */ - font-weight: bold; - text-decoration: none; - color: var(--link-color); - display: block; - flex: 1; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 1.8rem; + font-weight: bold; + text-decoration: none; + color: var(--link-color); + display: block; + flex: 1; } .item-title:hover { - text-decoration: none; - color: var(--link-color); + text-decoration: none; + color: var(--link-color); } .item-actions { - display: flex; - gap: 0.5rem; - margin-left: 1rem; + display: flex; + gap: 0.5rem; + margin-left: 1rem; } /* Legacy controls were simple text/links, but buttons are fine if minimal */ .star-btn { - background: none; - border: none; - cursor: pointer; - font-size: 1.25rem; - padding: 0 0 0 0.5rem; - vertical-align: middle; - transition: color 0.2s; - line-height: 1; + background: none; + border: none; + cursor: pointer; + font-size: 1.25rem; + padding: 0 0 0 0.5rem; + vertical-align: middle; + transition: color 0.2s; + line-height: 1; } .star-btn.is-starred { - color: blue; + color: blue; } .star-btn.is-unstarred { - color: black; + color: black; } .star-btn:hover { - color: blue; + color: blue; } .action-btn { - background: whitesmoke; - border: none; - cursor: pointer; - padding: 2px 6px; - font-size: 1rem; - color: blue; - font-weight: bold; + background: whitesmoke; + border: none; + cursor: pointer; + padding: 2px 6px; + font-size: 1rem; + color: blue; + font-weight: bold; } .action-btn:hover { - background-color: #eee; + background-color: #eee; } .dateline { - margin-top: 0; - font-weight: normal; - font-size: .75em; - color: #ccc; - margin-bottom: 1rem; + margin-top: 0; + font-weight: normal; + font-size: 0.75em; + color: #ccc; + margin-bottom: 1rem; } .dateline a { - color: #ccc; - text-decoration: none; + color: #ccc; + text-decoration: none; } .item-description { - color: #000; - line-height: 1.5; - font-size: 1rem; - margin-top: 1rem; + color: var(--text-color); + line-height: 1.5; + font-size: 1rem; + margin-top: 1rem; } .item-description img { - max-width: 100%; - height: auto; - display: block; - margin: 1rem 0; + max-width: 100%; + height: auto; + display: block; + margin: 1rem 0; } .item-description blockquote { - padding: 1rem 1rem 0 1rem; - border-left: 4px solid #ddd; - color: #666; - margin-left: 0; + padding: 1rem 1rem 0 1rem; + border-left: 4px solid #ddd; + color: #666; + margin-left: 0; }
\ No newline at end of file diff --git a/frontend/src/components/FeedItem.test.tsx b/frontend/src/components/FeedItem.test.tsx index f0497c6..cb9aafa 100644 --- a/frontend/src/components/FeedItem.test.tsx +++ b/frontend/src/components/FeedItem.test.tsx @@ -6,66 +6,69 @@ import FeedItem from './FeedItem'; import type { Item } from '../types'; const mockItem: Item = { - _id: 1, - feed_id: 101, - title: 'Test Item', - url: 'http://example.com/item', - description: '<p>Description</p>', - publish_date: '2023-01-01', - read: false, - starred: false, - feed_title: 'Test Feed' + _id: 1, + feed_id: 101, + title: 'Test Item', + url: 'http://example.com/item', + description: '<p>Description</p>', + publish_date: '2023-01-01', + read: false, + starred: false, + feed_title: 'Test Feed', }; describe('FeedItem Component', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); - it('renders item details', () => { - render(<FeedItem item={mockItem} />); - expect(screen.getByText('Test Item')).toBeInTheDocument(); - expect(screen.getByText(/Test Feed/)).toBeInTheDocument(); - // Check for relative time or date formatting? For now just check it renders - }); + it('renders item details', () => { + render(<FeedItem item={mockItem} />); + expect(screen.getByText('Test Item')).toBeInTheDocument(); + expect(screen.getByText(/Test Feed/)).toBeInTheDocument(); + // Check for relative time or date formatting? For now just check it renders + }); - it('toggles star status', async () => { - (global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + it('toggles star status', async () => { + (global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }); - render(<FeedItem item={mockItem} />); + render(<FeedItem item={mockItem} />); - const starBtn = screen.getByTitle('Star'); - expect(starBtn).toHaveTextContent('★'); - fireEvent.click(starBtn); + const starBtn = screen.getByTitle('Star'); + expect(starBtn).toHaveTextContent('★'); + fireEvent.click(starBtn); - // Optimistic update - expect(await screen.findByTitle('Unstar')).toHaveTextContent('★'); + // Optimistic update + expect(await screen.findByTitle('Unstar')).toHaveTextContent('★'); - expect(global.fetch).toHaveBeenCalledWith('/api/item/1', expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ - _id: 1, - read: false, - starred: true - }) - })); - }); + expect(global.fetch).toHaveBeenCalledWith( + '/api/item/1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + _id: 1, + read: false, + starred: true, + }), + }) + ); + }); - it('updates styling when read state changes', () => { - const { rerender } = render(<FeedItem item={{ ...mockItem, read: false }} />); - const link = screen.getByText('Test Item'); - // Initial state: unread (bold) - // Note: checking computed style might be flaky in jsdom, but we can check the class on the parent - const listItem = link.closest('li'); - expect(listItem).toHaveClass('unread'); - expect(listItem).not.toHaveClass('read'); + it('updates styling when read state changes', () => { + const { rerender } = render(<FeedItem item={{ ...mockItem, read: false }} />); + const link = screen.getByText('Test Item'); + // Initial state: unread (bold) + // Note: checking computed style might be flaky in jsdom, but we can check the class on the parent + const listItem = link.closest('li'); + expect(listItem).toHaveClass('unread'); + expect(listItem).not.toHaveClass('read'); - // Update prop to read - rerender(<FeedItem item={{ ...mockItem, read: true }} />); + // Update prop to read + rerender(<FeedItem item={{ ...mockItem, read: true }} />); - // Should now be read - expect(listItem).toHaveClass('read'); - expect(listItem).not.toHaveClass('unread'); - }); + // Should now be read + expect(listItem).toHaveClass('read'); + expect(listItem).not.toHaveClass('unread'); + }); }); diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx index b86e60c..9b40114 100644 --- a/frontend/src/components/FeedItem.tsx +++ b/frontend/src/components/FeedItem.tsx @@ -3,86 +3,84 @@ import type { Item } from '../types'; import './FeedItem.css'; interface FeedItemProps { - item: Item; + item: Item; } export default function FeedItem({ item: initialItem }: FeedItemProps) { - const [item, setItem] = useState(initialItem); - const [loading, setLoading] = useState(false); + const [item, setItem] = useState(initialItem); + const [loading, setLoading] = useState(false); - useEffect(() => { - setItem(initialItem); - }, [initialItem]); + useEffect(() => { + setItem(initialItem); + }, [initialItem]); + const toggleStar = () => { + updateItem({ ...item, starred: !item.starred }); + }; - const toggleStar = () => { - updateItem({ ...item, starred: !item.starred }); - }; + const updateItem = (newItem: Item) => { + setLoading(true); + // Optimistic update + const previousItem = item; + setItem(newItem); - const updateItem = (newItem: Item) => { - setLoading(true); - // Optimistic update - const previousItem = item; - setItem(newItem); + fetch(`/api/item/${newItem._id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + _id: newItem._id, + read: newItem.read, + starred: newItem.starred, + }), + }) + .then((res) => { + if (!res.ok) { + throw new Error('Failed to update item'); + } + return res.json(); + }) + .then(() => { + // Confirm with server response if needed, but for now we trust the optimistic update + // or we could setItem(updated) if the server returns the full object + setLoading(false); + }) + .catch((err) => { + console.error('Error updating item:', err); + // Revert on error + setItem(previousItem); + setLoading(false); + }); + }; - fetch(`/api/item/${newItem._id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - _id: newItem._id, - read: newItem.read, - starred: newItem.starred, - }), - }) - .then((res) => { - if (!res.ok) { - throw new Error('Failed to update item'); - } - return res.json(); - }) - .then(() => { - // Confirm with server response if needed, but for now we trust the optimistic update - // or we could setItem(updated) if the server returns the full object - setLoading(false); - }) - .catch((err) => { - console.error('Error updating item:', err); - // Revert on error - setItem(previousItem); - setLoading(false); - }); - }; - - return ( - <li className={`feed-item ${item.read ? 'read' : 'unread'} ${loading ? 'loading' : ''}`}> - <div className="item-header"> - <a href={item.url} target="_blank" rel="noopener noreferrer" className="item-title"> - {item.title || '(No Title)'} - </a> - <button - onClick={(e) => { - e.stopPropagation(); - toggleStar(); - }} - className={`star-btn ${item.starred ? 'is-starred' : 'is-unstarred'}`} - title={item.starred ? "Unstar" : "Star"} - > - ★ - </button> - </div> - <div className="dateline"> - <a href={item.url} target="_blank" rel="noopener noreferrer"> - {new Date(item.publish_date).toLocaleDateString()} - {item.feed_title && ` - ${item.feed_title}`} - </a> - <div className="item-actions" style={{ display: 'inline-block', float: 'right' }}> - </div> - </div> - {item.description && ( - <div className="item-description" dangerouslySetInnerHTML={{ __html: item.description }} /> - )} - </li> - ); + return ( + <li className={`feed-item ${item.read ? 'read' : 'unread'} ${loading ? 'loading' : ''}`}> + <div className="item-header"> + <a href={item.url} target="_blank" rel="noopener noreferrer" className="item-title"> + {item.title || '(No Title)'} + </a> + <button + onClick={(e) => { + e.stopPropagation(); + toggleStar(); + }} + className={`star-btn ${item.starred ? 'is-starred' : 'is-unstarred'}`} + title={item.starred ? 'Unstar' : 'Star'} + > + ★ + </button> + </div> + <div className="dateline"> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {new Date(item.publish_date).toLocaleDateString()} + {item.feed_title && ` - ${item.feed_title}`} + </a> + <div className="item-actions" style={{ display: 'inline-block', float: 'right' }}></div> + </div> + {item.description && ( + <div className="item-description" dangerouslySetInnerHTML={{ __html: item.description }} /> + )} + </li> + ); } diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css index 31394a4..02323a9 100644 --- a/frontend/src/components/FeedItems.css +++ b/frontend/src/components/FeedItems.css @@ -1,22 +1,23 @@ .feed-items { - padding: 1rem; + padding: 1rem 0; + /* Removing horizontal padding to avoid double-padding with FeedItem */ } .feed-items h2 { - margin-top: 0; - border-bottom: 2px solid #eee; - padding-bottom: 0.5rem; + margin-top: 0; + border-bottom: 2px solid #eee; + padding-bottom: 0.5rem; } .item-list { - list-style: none; - padding: 0; + list-style: none; + padding: 0; } .loading-more { - padding: 2rem; - text-align: center; - color: #888; - font-size: 0.9rem; - min-height: 50px; + 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 ea68a7c..4d96da9 100644 --- a/frontend/src/components/FeedItems.test.tsx +++ b/frontend/src/components/FeedItems.test.tsx @@ -6,220 +6,241 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import FeedItems from './FeedItems'; 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; + 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', () => { + (global.fetch as any).mockImplementation(() => new Promise(() => {})); + render( + <MemoryRouter initialEntries={['/feed/1']}> + <Routes> + <Route path="/feed/:feedId" element={<FeedItems />} /> + </Routes> + </MemoryRouter> + ); + expect(screen.getByText(/loading items/i)).toBeInTheDocument(); + }); + + it('renders items for a feed', async () => { + const mockItems = [ + { + _id: 101, + title: 'Item One', + url: 'http://example.com/1', + publish_date: '2023-01-01', + read: false, + }, + { + _id: 102, + title: 'Item Two', + url: 'http://example.com/2', + publish_date: '2023-01-02', + read: true, + }, + ]; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockItems, }); - it('renders loading state', () => { - (global.fetch as any).mockImplementation(() => new Promise(() => { })); - render( - <MemoryRouter initialEntries={['/feed/1']}> - <Routes> - <Route path="/feed/:feedId" element={<FeedItems />} /> - </Routes> - </MemoryRouter> - ); - expect(screen.getByText(/loading items/i)).toBeInTheDocument(); + render( + <MemoryRouter initialEntries={['/feed/1']}> + <Routes> + <Route path="/feed/:feedId" element={<FeedItems />} /> + </Routes> + </MemoryRouter> + ); + + await waitFor(() => { + expect(screen.getByText('Item One')).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( + <MemoryRouter> + <FeedItems /> + </MemoryRouter> + ); + + 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('renders items for a feed', async () => { - const mockItems = [ - { _id: 101, title: 'Item One', url: 'http://example.com/1', publish_date: '2023-01-01', read: false }, - { _id: 102, title: 'Item Two', url: 'http://example.com/2', publish_date: '2023-01-02', read: true }, - ]; - - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockItems, - }); - - render( - <MemoryRouter initialEntries={['/feed/1']}> - <Routes> - <Route path="/feed/:feedId" element={<FeedItems />} /> - </Routes> - </MemoryRouter> - ); - - await waitFor(() => { - expect(screen.getByText('Item One')).toBeInTheDocument(); - }); - - const params = new URLSearchParams(); - params.append('feed_id', '1'); - params.append('read_filter', 'unread'); - expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`); + 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, }); - 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( - <MemoryRouter> - <FeedItems /> - </MemoryRouter> - ); - - 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 - })); - }); + // 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( + <MemoryRouter> + <FeedItems /> + </MemoryRouter> + ); + + 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 }), + }) + ); + }); + }); + + 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(); }); - 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, - }); - - // 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( - <MemoryRouter> - <FeedItems /> - </MemoryRouter> - ); - - 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 }), - })); - }); + // 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); }); - 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()}`); - }); + 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 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> + ); } diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css index 0d6d26d..ff0f41b 100644 --- a/frontend/src/components/FeedList.css +++ b/frontend/src/components/FeedList.css @@ -1,170 +1,170 @@ .feed-list { - /* Removed card styling */ - padding: 0; - background: transparent; + /* Removed card styling */ + padding: 0; + background: transparent; } .search-section { - margin-bottom: 1.5rem; + margin-bottom: 1.5rem; } .search-form { - display: flex; + display: flex; } .search-input { - width: 100%; - padding: 0.5rem; - border: 1px solid #999; - background: #eee; - font-size: 1rem; - font-family: inherit; + width: 100%; + padding: 0.5rem; + border: 1px solid #999; + background: #eee; + font-size: 1rem; + font-family: inherit; } .search-input:focus { - outline: none; - background: white; - border-color: #000; + outline: none; + background: white; + border-color: #000; } .feed-list h2, .feed-section-header { - font-size: 1.2rem; - margin-bottom: 0.5rem; - border-bottom: 1px solid #999; - padding-bottom: 0.25rem; - text-transform: uppercase; - letter-spacing: 1px; - cursor: pointer; - user-select: none; - display: flex; - align-items: center; + font-size: 1.2rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid #999; + padding-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; } .toggle-indicator { - font-size: 0.8rem; - margin-right: 0.5rem; - display: inline-block; - width: 1rem; - text-align: center; + font-size: 0.8rem; + margin-right: 0.5rem; + display: inline-block; + width: 1rem; + text-align: center; } .feed-list-items, .tag-list-items, .filter-list { - list-style: none; - padding: 0; - margin: 0; + list-style: none; + padding: 0; + margin: 0; } .sidebar-feed-item { - padding: 0.25rem 0; - border-bottom: none; - /* Clean look */ - display: flex; - justify-content: space-between; - align-items: center; + padding: 0.25rem 0; + border-bottom: none; + /* Clean look */ + display: flex; + justify-content: space-between; + align-items: center; } .feed-title { - color: var(--link-color); - text-decoration: none; - font-size: 0.9rem; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + color: var(--link-color); + text-decoration: none; + font-size: 0.9rem; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } .feed-title:hover { - text-decoration: underline; - color: var(--link-color); + text-decoration: underline; + color: var(--link-color); } .feed-category { - display: none; - /* Hide category in sidebar list to save space */ + display: none; + /* Hide category in sidebar list to save space */ } .tag-section { - margin-top: 2rem; + margin-top: 2rem; } .tag-link { - color: var(--link-color); - text-decoration: none; - font-size: 0.9rem; - display: block; - padding: 0.1rem 0; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + color: var(--link-color); + text-decoration: none; + font-size: 0.9rem; + display: block; + padding: 0.1rem 0; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } .tag-link:hover { - text-decoration: underline; - background: transparent; - color: var(--link-color); + text-decoration: underline; + background: transparent; + color: var(--link-color); } .filter-section { - margin-bottom: 2rem; + margin-bottom: 2rem; } .filter-list { - display: block; - list-style: none; - padding: 0; - margin: 0; + display: block; + list-style: none; + padding: 0; + margin: 0; } .filter-list li a { - text-decoration: none; - color: #333; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-variant: small-caps; - text-transform: lowercase; - font-size: 1.1rem; - display: block; - margin-bottom: 0.5rem; + text-decoration: none; + color: var(--text-color); + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-variant: small-caps; + text-transform: lowercase; + font-size: 1.1rem; + display: block; + margin-bottom: 0.5rem; } .filter-list li a:hover { - color: blue; - background-color: transparent; - text-decoration: underline; + color: blue; + background-color: transparent; + text-decoration: underline; } .feed-title.active, .tag-link.active, .filter-list li a.active, .theme-selector button.active { - font-weight: bold !important; + font-weight: bold !important; } .theme-section { - margin-top: 2rem; - padding-bottom: 2rem; + margin-top: 2rem; + padding-bottom: 2rem; } .theme-selector { - display: flex; - justify-content: space-between; - gap: 5px; + display: flex; + justify-content: space-between; + gap: 5px; } .theme-selector button { - font-size: 0.8rem; - padding: 0.2rem 0.5rem; - width: 30%; - background: whitesmoke; - color: blue; - border: 1px solid #ccc; - border-radius: 4px; - font-variant: small-caps; - text-transform: lowercase; + font-size: 0.8rem; + padding: 0.2rem 0.5rem; + width: 30%; + background: whitesmoke; + color: blue; + border: 1px solid #ccc; + border-radius: 4px; + font-variant: small-caps; + text-transform: lowercase; } .theme-selector button:hover { - background: #eee; + background: #eee; } .theme-selector button.active { - color: black; - border-color: #000; -}
\ No newline at end of file + color: black; + border-color: #000; +} diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx index d5f49b7..daa4d69 100644 --- a/frontend/src/components/FeedList.test.tsx +++ b/frontend/src/components/FeedList.test.tsx @@ -7,114 +7,126 @@ import FeedList from './FeedList'; import { BrowserRouter } from 'react-router-dom'; describe('FeedList Component', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); - - it('renders loading state initially', () => { - (global.fetch as any).mockImplementation(() => new Promise(() => { })); - render( - <BrowserRouter> - {/* @ts-ignore */} - <FeedList theme="light" setTheme={() => { }} /> - </BrowserRouter> - ); - expect(screen.getByText(/loading feeds/i)).toBeInTheDocument(); - }); - - it('renders list of feeds', async () => { - const mockFeeds = [ - { _id: 1, title: 'Feed One', url: 'http://example.com/rss', web_url: 'http://example.com', category: 'Tech' }, - { _id: 2, title: 'Feed Two', url: 'http://test.com/rss', web_url: 'http://test.com', category: 'News' }, - ]; - - (global.fetch as any).mockImplementation((url: string) => { - if (url.includes('/api/feed/')) { - return Promise.resolve({ - ok: true, - json: async () => mockFeeds, - }); - } - if (url.includes('/api/tag')) { - return Promise.resolve({ - ok: true, - json: async () => [{ title: 'Tech' }], - }); - } - return Promise.reject(new Error(`Unknown URL: ${url}`)); + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); + + it('renders loading state initially', () => { + (global.fetch as any).mockImplementation(() => new Promise(() => {})); + render( + <BrowserRouter> + {/* @ts-ignore */} + <FeedList theme="light" setTheme={() => {}} /> + </BrowserRouter> + ); + expect(screen.getByText(/loading feeds/i)).toBeInTheDocument(); + }); + + it('renders list of feeds', async () => { + const mockFeeds = [ + { + _id: 1, + title: 'Feed One', + url: 'http://example.com/rss', + web_url: 'http://example.com', + category: 'Tech', + }, + { + _id: 2, + title: 'Feed Two', + url: 'http://test.com/rss', + web_url: 'http://test.com', + category: 'News', + }, + ]; + + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/feed/')) { + return Promise.resolve({ + ok: true, + json: async () => mockFeeds, }); + } + if (url.includes('/api/tag')) { + return Promise.resolve({ + ok: true, + json: async () => [{ title: 'Tech' }], + }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); - render( - <BrowserRouter> - {/* @ts-ignore */} - <FeedList theme="light" setTheme={() => { }} /> - </BrowserRouter> - ); + render( + <BrowserRouter> + {/* @ts-ignore */} + <FeedList theme="light" setTheme={() => {}} /> + </BrowserRouter> + ); - await waitFor(() => { - expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument(); + }); - // Expand feeds - fireEvent.click(screen.getByText(/feeds/i, { selector: 'h2' })); + // Expand feeds + fireEvent.click(screen.getByText(/feeds/i, { selector: 'h2' })); - await waitFor(() => { - expect(screen.getByText('Feed One')).toBeInTheDocument(); - expect(screen.getByText('Feed Two')).toBeInTheDocument(); - const techElements = screen.getAllByText('Tech'); - expect(techElements.length).toBeGreaterThan(0); - }); + await waitFor(() => { + expect(screen.getByText('Feed One')).toBeInTheDocument(); + expect(screen.getByText('Feed Two')).toBeInTheDocument(); + const techElements = screen.getAllByText('Tech'); + expect(techElements.length).toBeGreaterThan(0); }); + }); - it('handles fetch error', async () => { - (global.fetch as any).mockImplementation(() => Promise.reject(new Error('API Error'))); + it('handles fetch error', async () => { + (global.fetch as any).mockImplementation(() => Promise.reject(new Error('API Error'))); - render( - <BrowserRouter> - {/* @ts-ignore */} - <FeedList theme="light" setTheme={() => { }} /> - </BrowserRouter> - ); + render( + <BrowserRouter> + {/* @ts-ignore */} + <FeedList theme="light" setTheme={() => {}} /> + </BrowserRouter> + ); - await waitFor(() => { - expect(screen.getByText(/error: api error/i)).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText(/error: api error/i)).toBeInTheDocument(); }); - - it('handles empty feed list', async () => { - (global.fetch as any).mockImplementation((url: string) => { - if (url.includes('/api/feed/')) { - return Promise.resolve({ - ok: true, - json: async () => [], - }); - } - if (url.includes('/api/tag')) { - return Promise.resolve({ - ok: true, - json: async () => [], - }); - } - return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); + + it('handles empty feed list', async () => { + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/feed/')) { + return Promise.resolve({ + ok: true, + json: async () => [], + }); + } + if (url.includes('/api/tag')) { + return Promise.resolve({ + ok: true, + json: async () => [], }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); - render( - <BrowserRouter> - {/* @ts-ignore */} - <FeedList theme="light" setTheme={() => { }} /> - </BrowserRouter> - ); + render( + <BrowserRouter> + {/* @ts-ignore */} + <FeedList theme="light" setTheme={() => {}} /> + </BrowserRouter> + ); - await waitFor(() => { - expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument(); + }); - // Expand feeds - fireEvent.click(screen.getByText(/feeds/i, { selector: 'h2' })); + // Expand feeds + fireEvent.click(screen.getByText(/feeds/i, { selector: 'h2' })); - await waitFor(() => { - expect(screen.getByText(/no feeds found/i)).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText(/no feeds found/i)).toBeInTheDocument(); }); + }); }); diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index 56c96cd..497baf8 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -3,121 +3,151 @@ import { Link, useNavigate, useSearchParams, useLocation, useParams } from 'reac import type { Feed, Category } from '../types'; import './FeedList.css'; -export default function FeedList({ theme, setTheme }: { theme: string, setTheme: (t: string) => void }) { - const [feeds, setFeeds] = useState<Feed[]>([]); - const [tags, setTags] = useState<Category[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [feedsExpanded, setFeedsExpanded] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const location = useLocation(); - const { feedId, tagName } = useParams(); +export default function FeedList({ + theme, + setTheme, +}: { + theme: string; + setTheme: (t: string) => void; +}) { + const [feeds, setFeeds] = useState<Feed[]>([]); + const [tags, setTags] = useState<Category[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [feedsExpanded, setFeedsExpanded] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const location = useLocation(); + const { feedId, tagName } = useParams(); - const currentFilter = searchParams.get('filter') || (location.pathname === '/' && !feedId && !tagName ? 'unread' : ''); + const currentFilter = + searchParams.get('filter') || + (location.pathname === '/' && !feedId && !tagName ? 'unread' : ''); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - if (searchQuery.trim()) { - navigate(`/?q=${encodeURIComponent(searchQuery.trim())}`); - } - }; + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + navigate(`/?q=${encodeURIComponent(searchQuery.trim())}`); + } + }; - const toggleFeeds = () => { - setFeedsExpanded(!feedsExpanded); - }; + const toggleFeeds = () => { + setFeedsExpanded(!feedsExpanded); + }; - useEffect(() => { - Promise.all([ - fetch('/api/feed/').then(res => { - if (!res.ok) throw new Error('Failed to fetch feeds'); - return res.json(); - }), - fetch('/api/tag').then(res => { - if (!res.ok) throw new Error('Failed to fetch tags'); - return res.json(); - }) - ]) - .then(([feedsData, tagsData]) => { - setFeeds(feedsData); - setTags(tagsData); - setLoading(false); - }) - .catch((err) => { - setError(err.message); - setLoading(false); - }); - }, []); + useEffect(() => { + Promise.all([ + fetch('/api/feed/').then((res) => { + if (!res.ok) throw new Error('Failed to fetch feeds'); + return res.json(); + }), + fetch('/api/tag').then((res) => { + if (!res.ok) throw new Error('Failed to fetch tags'); + return res.json(); + }), + ]) + .then(([feedsData, tagsData]) => { + setFeeds(feedsData); + setTags(tagsData); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }, []); - if (loading) return <div className="feed-list-loading">Loading feeds...</div>; - if (error) return <div className="feed-list-error">Error: {error}</div>; + if (loading) return <div className="feed-list-loading">Loading feeds...</div>; + if (error) return <div className="feed-list-error">Error: {error}</div>; - return ( - <div className="feed-list"> - <div className="search-section"> - <form onSubmit={handleSearch} className="search-form"> - <input - type="search" - placeholder="Search items..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - className="search-input" - /> - </form> - </div> - <div className="filter-section"> - <ul className="filter-list"> - <li><Link to="/?filter=unread" className={currentFilter === 'unread' ? 'active' : ''}>Unread</Link></li> - <li><Link to="/?filter=all" className={currentFilter === 'all' ? 'active' : ''}>All</Link></li> - <li><Link to="/?filter=starred" className={currentFilter === 'starred' ? 'active' : ''}>Starred</Link></li> - </ul> - </div> - <div className="feed-section"> - <h2 onClick={toggleFeeds} className="feed-section-header"> - <span className="toggle-indicator">{feedsExpanded ? '▼' : '▶'}</span> Feeds - </h2> - {feedsExpanded && ( - feeds.length === 0 ? ( - <p>No feeds found.</p> - ) : ( - <ul className="feed-list-items"> - {feeds.map((feed) => ( - <li key={feed._id} className="sidebar-feed-item"> - <Link to={`/feed/${feed._id}`} className={`feed-title ${feedId === String(feed._id) ? 'active' : ''}`}> - {feed.title || feed.url} - </Link> - {feed.category && <span className="feed-category">{feed.category}</span>} - </li> - ))} - </ul> - ) - )} - </div> + return ( + <div className="feed-list"> + <div className="search-section"> + <form onSubmit={handleSearch} className="search-form"> + <input + type="search" + placeholder="Search items..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="search-input" + /> + </form> + </div> + <div className="filter-section"> + <ul className="filter-list"> + <li> + <Link to="/?filter=unread" className={currentFilter === 'unread' ? 'active' : ''}> + Unread + </Link> + </li> + <li> + <Link to="/?filter=all" className={currentFilter === 'all' ? 'active' : ''}> + All + </Link> + </li> + <li> + <Link to="/?filter=starred" className={currentFilter === 'starred' ? 'active' : ''}> + Starred + </Link> + </li> + </ul> + </div> + <div className="feed-section"> + <h2 onClick={toggleFeeds} className="feed-section-header"> + <span className="toggle-indicator">{feedsExpanded ? '▼' : '▶'}</span> Feeds + </h2> + {feedsExpanded && + (feeds.length === 0 ? ( + <p>No feeds found.</p> + ) : ( + <ul className="feed-list-items"> + {feeds.map((feed) => ( + <li key={feed._id} className="sidebar-feed-item"> + <Link + to={`/feed/${feed._id}`} + className={`feed-title ${feedId === String(feed._id) ? 'active' : ''}`} + > + {feed.title || feed.url} + </Link> + {feed.category && <span className="feed-category">{feed.category}</span>} + </li> + ))} + </ul> + ))} + </div> - {tags && tags.length > 0 && ( - <div className="tag-section"> - <h2>Tags</h2> - <ul className="tag-list-items"> - {tags.map((tag) => ( - <li key={tag.title} className="tag-item"> - <Link to={`/tag/${encodeURIComponent(tag.title)}`} className={`tag-link ${tagName === tag.title ? 'active' : ''}`}> - {tag.title} - </Link> - </li> - ))} - </ul> - </div> - )} + {tags && tags.length > 0 && ( + <div className="tag-section"> + <h2>Tags</h2> + <ul className="tag-list-items"> + {tags.map((tag) => ( + <li key={tag.title} className="tag-item"> + <Link + to={`/tag/${encodeURIComponent(tag.title)}`} + className={`tag-link ${tagName === tag.title ? 'active' : ''}`} + > + {tag.title} + </Link> + </li> + ))} + </ul> + </div> + )} - <div className="theme-section"> - <h2>Themes</h2> - <div className="theme-selector"> - <button onClick={() => setTheme('light')} className={theme === 'light' ? 'active' : ''}>light</button> - <button onClick={() => setTheme('dark')} className={theme === 'dark' ? 'active' : ''}>dark</button> - <button onClick={() => setTheme('black')} className={theme === 'black' ? 'active' : ''}>black</button> - </div> - </div> + <div className="theme-section"> + <div className="theme-selector"> + <button onClick={() => setTheme('light')} className={theme === 'light' ? 'active' : ''}> + light + </button> + <button onClick={() => setTheme('dark')} className={theme === 'dark' ? 'active' : ''}> + dark + </button> + <button onClick={() => setTheme('black')} className={theme === 'black' ? 'active' : ''}> + black + </button> </div> - ); + </div> + </div> + ); } diff --git a/frontend/src/components/Login.css b/frontend/src/components/Login.css index f1ca976..6f40731 100644 --- a/frontend/src/components/Login.css +++ b/frontend/src/components/Login.css @@ -46,7 +46,7 @@ text-align: center; } -button[type="submit"] { +button[type='submit'] { width: 100%; padding: 0.75rem; background-color: #007bff; @@ -58,6 +58,6 @@ button[type="submit"] { transition: background-color 0.2s; } -button[type="submit"]:hover { +button[type='submit']:hover { background-color: #0056b3; } diff --git a/frontend/src/components/Login.test.tsx b/frontend/src/components/Login.test.tsx index ef946e2..aea7042 100644 --- a/frontend/src/components/Login.test.tsx +++ b/frontend/src/components/Login.test.tsx @@ -9,70 +9,73 @@ import Login from './Login'; global.fetch = vi.fn(); const renderLogin = () => { - render( - <BrowserRouter> - <Login /> - </BrowserRouter> - ); + render( + <BrowserRouter> + <Login /> + </BrowserRouter> + ); }; describe('Login Component', () => { - beforeEach(() => { - vi.resetAllMocks(); + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders login form', () => { + renderLogin(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); + }); + + it('handles successful login', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, }); - it('renders login form', () => { - renderLogin(); - expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument(); - }); - - it('handles successful login', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - }); - - renderLogin(); + renderLogin(); - fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); - fireEvent.click(screen.getByRole('button', { name: /login/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith('/api/login', expect.objectContaining({ - method: 'POST', - })); - }); - // Navigation assertion is tricky without mocking useNavigate, - // but if no error is shown, we assume success path was taken - expect(screen.queryByText(/login failed/i)).not.toBeInTheDocument(); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/login', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + // Navigation assertion is tricky without mocking useNavigate, + // but if no error is shown, we assume success path was taken + expect(screen.queryByText(/login failed/i)).not.toBeInTheDocument(); + }); + + it('handles failed login', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Bad credentials' }), }); - it('handles failed login', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: false, - json: async () => ({ message: 'Bad credentials' }), - }); - - renderLogin(); + renderLogin(); - fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); - fireEvent.click(screen.getByRole('button', { name: /login/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); - await waitFor(() => { - expect(screen.getByText(/bad credentials/i)).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText(/bad credentials/i)).toBeInTheDocument(); }); + }); - it('handles network error', async () => { - (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + it('handles network error', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); - renderLogin(); + renderLogin(); - fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); - fireEvent.click(screen.getByRole('button', { name: /login/i })); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: /login/i })); - await waitFor(() => { - expect(screen.getByText(/network error/i)).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText(/network error/i)).toBeInTheDocument(); }); + }); }); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 2e8bbf7..5f63248 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -3,52 +3,52 @@ import { useNavigate } from 'react-router-dom'; import './Login.css'; export default function Login() { - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const navigate = useNavigate(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const navigate = useNavigate(); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setError(''); + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); - try { - // Use URLSearchParams to send as form-urlencoded, matching backend expectation - const params = new URLSearchParams(); - params.append('password', password); + try { + // Use URLSearchParams to send as form-urlencoded, matching backend expectation + const params = new URLSearchParams(); + params.append('password', password); - const res = await fetch('/api/login', { - method: 'POST', - body: params, - }); + const res = await fetch('/api/login', { + method: 'POST', + body: params, + }); - if (res.ok) { - navigate('/'); - } else { - const data = await res.json(); - setError(data.message || 'Login failed'); - } - } catch (err) { - setError('Network error'); - } - }; + if (res.ok) { + navigate('/'); + } else { + const data = await res.json(); + setError(data.message || 'Login failed'); + } + } catch (err) { + setError('Network error'); + } + }; - return ( - <div className="login-container"> - <form onSubmit={handleSubmit} className="login-form"> - <h1>neko rss mode</h1> - <div className="form-group"> - <label htmlFor="password">password</label> - <input - id="password" - type="password" - value={password} - onChange={(e) => setPassword(e.target.value)} - autoFocus - /> - </div> - {error && <div className="error-message">{error}</div>} - <button type="submit">login</button> - </form> + return ( + <div className="login-container"> + <form onSubmit={handleSubmit} className="login-form"> + <h1>neko rss mode</h1> + <div className="form-group"> + <label htmlFor="password">password</label> + <input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + autoFocus + /> </div> - ); + {error && <div className="error-message">{error}</div>} + <button type="submit">login</button> + </form> + </div> + ); } diff --git a/frontend/src/components/Settings.css b/frontend/src/components/Settings.css index 4065e88..6e74475 100644 --- a/frontend/src/components/Settings.css +++ b/frontend/src/components/Settings.css @@ -1,83 +1,84 @@ .settings-page { - padding: 2rem; - max-width: 800px; - margin: 0 auto; + padding: 2rem; + max-width: 800px; + margin: 0 auto; } .add-feed-section { - background: #f9f9f9; - padding: 1.5rem; - border-radius: 8px; - margin-bottom: 2rem; - border: 1px solid #eee; + background: #f9f9f9; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 2rem; + border: 1px solid #eee; } .add-feed-form { - display: flex; - gap: 1rem; + display: flex; + gap: 1rem; } .feed-input { - flex: 1; - padding: 0.5rem; - border: 1px solid #ccc; - border-radius: 4px; - font-size: 1rem; + flex: 1; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; } .error-message { - color: #d32f2f; - margin-top: 1rem; + color: #d32f2f; + margin-top: 1rem; } .settings-feed-list { - list-style: none; - padding: 0; - border: 1px solid #eee; - border-radius: 8px; + list-style: none; + padding: 0; + border: 1px solid #eee; + border-radius: 8px; } .settings-feed-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem; - border-bottom: 1px solid #eee; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid #eee; } .settings-feed-item:last-child { - border-bottom: none; + border-bottom: none; } .feed-info { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .feed-title { - font-weight: bold; - font-size: 1.1rem; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: bold; + font-size: 1.1rem; } .feed-url { - color: #666; - font-size: 0.9rem; + color: #666; + font-size: 0.9rem; } .delete-btn { - background: #ff5252; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; + background: #ff5252; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; } .delete-btn:hover { - background: #ff1744; + background: #ff1744; } .delete-btn:disabled { - background: #ffcdd2; - cursor: not-allowed; -}
\ No newline at end of file + background: #ffcdd2; + cursor: not-allowed; +} diff --git a/frontend/src/components/Settings.test.tsx b/frontend/src/components/Settings.test.tsx index a15192d..f46ce6f 100644 --- a/frontend/src/components/Settings.test.tsx +++ b/frontend/src/components/Settings.test.tsx @@ -5,88 +5,97 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import Settings from './Settings'; describe('Settings Component', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - // Mock confirm - global.confirm = vi.fn(() => true); + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + // Mock confirm + global.confirm = vi.fn(() => true); + }); + + it('renders feed list', async () => { + const mockFeeds = [ + { _id: 1, title: 'Tech News', url: 'http://tech.com/rss', category: 'tech' }, + { _id: 2, title: 'Gaming', url: 'http://gaming.com/rss', category: 'gaming' }, + ]; + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => mockFeeds, }); - it('renders feed list', async () => { - const mockFeeds = [ - { _id: 1, title: 'Tech News', url: 'http://tech.com/rss', category: 'tech' }, - { _id: 2, title: 'Gaming', url: 'http://gaming.com/rss', category: 'gaming' }, - ]; + render(<Settings />); - (global.fetch as any).mockResolvedValueOnce({ - ok: true, - json: async () => mockFeeds, - }); - - render(<Settings />); - - await waitFor(() => { - expect(screen.getByText('Tech News')).toBeInTheDocument(); - expect(screen.getByText('http://tech.com/rss')).toBeInTheDocument(); - expect(screen.getByText('Gaming')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('Tech News')).toBeInTheDocument(); + expect(screen.getByText('http://tech.com/rss')).toBeInTheDocument(); + expect(screen.getByText('Gaming')).toBeInTheDocument(); + }); + }); + + it('adds a new feed', async () => { + (global.fetch as any) + .mockResolvedValueOnce({ ok: true, json: async () => [] }) // Initial load + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) // Add feed + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ _id: 3, title: 'New Feed', url: 'http://new.com/rss' }], + }); // Refresh load + + render(<Settings />); + + // Wait for initial load to finish + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); - it('adds a new feed', async () => { - (global.fetch as any) - .mockResolvedValueOnce({ ok: true, json: async () => [] }) // Initial load - .mockResolvedValueOnce({ ok: true, json: async () => ({}) }) // Add feed - .mockResolvedValueOnce({ ok: true, json: async () => [{ _id: 3, title: 'New Feed', url: 'http://new.com/rss' }] }); // Refresh load - - render(<Settings />); - - // Wait for initial load to finish - await waitFor(() => { - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - }); - - const input = screen.getByPlaceholderText('https://example.com/feed.xml'); - const button = screen.getByText('Add Feed'); + const input = screen.getByPlaceholderText('https://example.com/feed.xml'); + const button = screen.getByText('Add Feed'); - fireEvent.change(input, { target: { value: 'http://new.com/rss' } }); - fireEvent.click(button); + fireEvent.change(input, { target: { value: 'http://new.com/rss' } }); + fireEvent.click(button); - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith('/api/feed/', expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ url: 'http://new.com/rss' }), - })); - }); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/feed/', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ url: 'http://new.com/rss' }), + }) + ); + }); - // Wait for refresh - await waitFor(() => { - expect(screen.getByText('New Feed')).toBeInTheDocument(); - }); + // Wait for refresh + await waitFor(() => { + expect(screen.getByText('New Feed')).toBeInTheDocument(); }); + }); - it('deletes a feed', async () => { - const mockFeeds = [ - { _id: 1, title: 'Tech News', url: 'http://tech.com/rss', category: 'tech' }, - ]; + it('deletes a feed', async () => { + const mockFeeds = [ + { _id: 1, title: 'Tech News', url: 'http://tech.com/rss', category: 'tech' }, + ]; - (global.fetch as any) - .mockResolvedValueOnce({ ok: true, json: async () => mockFeeds }) // Initial load - .mockResolvedValueOnce({ ok: true }); // Delete + (global.fetch as any) + .mockResolvedValueOnce({ ok: true, json: async () => mockFeeds }) // Initial load + .mockResolvedValueOnce({ ok: true }); // Delete - render(<Settings />); + render(<Settings />); - await waitFor(() => { - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(screen.getByText('Tech News')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + expect(screen.getByText('Tech News')).toBeInTheDocument(); + }); - const deleteBtn = screen.getByTitle('Delete Feed'); - fireEvent.click(deleteBtn); + const deleteBtn = screen.getByTitle('Delete Feed'); + fireEvent.click(deleteBtn); - await waitFor(() => { - expect(global.confirm).toHaveBeenCalled(); - expect(global.fetch).toHaveBeenCalledWith('/api/feed/1', expect.objectContaining({ method: 'DELETE' })); - expect(screen.queryByText('Tech News')).not.toBeInTheDocument(); - }); + await waitFor(() => { + expect(global.confirm).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledWith( + '/api/feed/1', + expect.objectContaining({ method: 'DELETE' }) + ); + expect(screen.queryByText('Tech News')).not.toBeInTheDocument(); }); + }); }); diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index def8ffe..b4f6a3b 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -3,119 +3,121 @@ import type { Feed } from '../types'; import './Settings.css'; export default function Settings() { - const [feeds, setFeeds] = useState<Feed[]>([]); - const [newFeedUrl, setNewFeedUrl] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); + const [feeds, setFeeds] = useState<Feed[]>([]); + const [newFeedUrl, setNewFeedUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); - useEffect(() => { - fetchFeeds(); - }, []); + const fetchFeeds = () => { + setLoading(true); + fetch('/api/feed/') + .then((res) => { + if (!res.ok) throw new Error('Failed to fetch feeds'); + return res.json(); + }) + .then((data) => { + setFeeds(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }; - const fetchFeeds = () => { - setLoading(true); - fetch('/api/feed/') - .then((res) => { - if (!res.ok) throw new Error('Failed to fetch feeds'); - return res.json(); - }) - .then((data) => { - setFeeds(data); - setLoading(false); - }) - .catch((err) => { - setError(err.message); - setLoading(false); - }); - }; + useEffect(() => { + fetchFeeds(); + }, []); - const handleAddFeed = (e: React.FormEvent) => { - e.preventDefault(); - if (!newFeedUrl) return; - setLoading(true); - fetch('/api/feed/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: newFeedUrl }), - }) - .then((res) => { - if (!res.ok) throw new Error('Failed to add feed'); - return res.json(); - }) - .then(() => { - setNewFeedUrl(''); - fetchFeeds(); // Refresh list (or we could append if server returns full feed object) - }) - .catch((err) => { - setError(err.message); - setLoading(false); - }); - }; - const handleDeleteFeed = (id: number) => { - if (!globalThis.confirm('Are you sure you want to delete this feed?')) return; + const handleAddFeed = (e: React.FormEvent) => { + e.preventDefault(); + if (!newFeedUrl) return; - setLoading(true); - fetch(`/api/feed/${id}`, { - method: 'DELETE', - }) - .then((res) => { - if (!res.ok) throw new Error('Failed to delete feed'); - setFeeds(feeds.filter((f) => f._id !== id)); - setLoading(false); - }) - .catch((err) => { - setError(err.message); - setLoading(false); - }); - }; + setLoading(true); + fetch('/api/feed/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: newFeedUrl }), + }) + .then((res) => { + if (!res.ok) throw new Error('Failed to add feed'); + return res.json(); + }) + .then(() => { + setNewFeedUrl(''); + fetchFeeds(); // Refresh list (or we could append if server returns full feed object) + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }; - return ( - <div className="settings-page"> - <h2>Settings</h2> + const handleDeleteFeed = (id: number) => { + if (!globalThis.confirm('Are you sure you want to delete this feed?')) return; - <div className="add-feed-section"> - <h3>Add New Feed</h3> - <form onSubmit={handleAddFeed} className="add-feed-form"> - <input - type="url" - value={newFeedUrl} - onChange={(e) => setNewFeedUrl(e.target.value)} - placeholder="https://example.com/feed.xml" - required - className="feed-input" - disabled={loading} - /> - <button type="submit" disabled={loading}> - Add Feed - </button> - </form> - {error && <p className="error-message">{error}</p>} - </div> + setLoading(true); + fetch(`/api/feed/${id}`, { + method: 'DELETE', + }) + .then((res) => { + if (!res.ok) throw new Error('Failed to delete feed'); + setFeeds(feeds.filter((f) => f._id !== id)); + setLoading(false); + }) + .catch((err) => { + setError(err.message); + setLoading(false); + }); + }; - <div className="feed-list-section"> - <h3>Manage Feeds</h3> - {loading && <p>Loading...</p>} - <ul className="settings-feed-list"> - {feeds.map((feed) => ( - <li key={feed._id} className="settings-feed-item"> - <div className="feed-info"> - <span className="feed-title">{feed.title || '(No Title)'}</span> - <span className="feed-url">{feed.url}</span> - </div> - <button - onClick={() => handleDeleteFeed(feed._id)} - className="delete-btn" - disabled={loading} - title="Delete Feed" - > - Delete - </button> - </li> - ))} - </ul> - </div> - </div> - ); + return ( + <div className="settings-page"> + <h2>Settings</h2> + + <div className="add-feed-section"> + <h3>Add New Feed</h3> + <form onSubmit={handleAddFeed} className="add-feed-form"> + <input + type="url" + value={newFeedUrl} + onChange={(e) => setNewFeedUrl(e.target.value)} + placeholder="https://example.com/feed.xml" + required + className="feed-input" + disabled={loading} + /> + <button type="submit" disabled={loading}> + Add Feed + </button> + </form> + {error && <p className="error-message">{error}</p>} + </div> + + <div className="feed-list-section"> + <h3>Manage Feeds</h3> + {loading && <p>Loading...</p>} + <ul className="settings-feed-list"> + {feeds.map((feed) => ( + <li key={feed._id} className="settings-feed-item"> + <div className="feed-info"> + <span className="feed-title">{feed.title || '(No Title)'}</span> + <span className="feed-url">{feed.url}</span> + </div> + <button + onClick={() => handleDeleteFeed(feed._id)} + className="delete-btn" + disabled={loading} + title="Delete Feed" + > + Delete + </button> + </li> + ))} + </ul> + </div> + </div> + ); } diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx index d19d4bb..10872bc 100644 --- a/frontend/src/components/TagView.test.tsx +++ b/frontend/src/components/TagView.test.tsx @@ -6,79 +6,81 @@ import FeedList from './FeedList'; import FeedItems from './FeedItems'; describe('Tag View Integration', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); + beforeEach(() => { + vi.resetAllMocks(); + global.fetch = vi.fn(); + }); - it('renders tags in FeedList and navigates to tag view', async () => { - const mockFeeds = [{ _id: 1, title: 'Feed 1', url: 'http://example.com/rss', category: 'Tech' }]; - const mockTags = [{ title: 'Tech' }, { title: 'News' }]; + it('renders tags in FeedList and navigates to tag view', async () => { + const mockFeeds = [ + { _id: 1, title: 'Feed 1', url: 'http://example.com/rss', category: 'Tech' }, + ]; + const mockTags = [{ title: 'Tech' }, { title: 'News' }]; - (global.fetch as any).mockImplementation((url: string) => { - if (url.includes('/api/feed/')) { - return Promise.resolve({ - ok: true, - json: async () => mockFeeds, - }); - } - if (url.includes('/api/tag')) { - return Promise.resolve({ - ok: true, - json: async () => mockTags, - }); - } - return Promise.reject(new Error(`Unknown URL: ${url}`)); + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/feed/')) { + return Promise.resolve({ + ok: true, + json: async () => mockFeeds, }); - - render( - <MemoryRouter> - <FeedList /> - </MemoryRouter> - ); - - await waitFor(() => { - const techTags = screen.getAllByText('Tech'); - expect(techTags.length).toBeGreaterThan(0); - expect(screen.getByText('News')).toBeInTheDocument(); + } + if (url.includes('/api/tag')) { + return Promise.resolve({ + ok: true, + json: async () => mockTags, }); - - // Verify structure - const techTag = screen.getByText('News').closest('a'); - expect(techTag).toHaveAttribute('href', '/tag/News'); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); }); - it('fetches items by tag in FeedItems', async () => { - const mockItems = [ - { _id: 101, title: 'Tag Item 1', url: 'http://example.com/1', feed_title: 'Feed 1' } - ]; + render( + <MemoryRouter> + <FeedList /> + </MemoryRouter> + ); - (global.fetch as any).mockImplementation((url: string) => { - if (url.includes('/api/stream')) { - return Promise.resolve({ - ok: true, - json: async () => mockItems, - }); - } - return Promise.reject(new Error(`Unknown URL: ${url}`)); - }); + await waitFor(() => { + const techTags = screen.getAllByText('Tech'); + expect(techTags.length).toBeGreaterThan(0); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + // Verify structure + const techTag = screen.getByText('News').closest('a'); + expect(techTag).toHaveAttribute('href', '/tag/News'); + }); - render( - <MemoryRouter initialEntries={['/tag/Tech']}> - <Routes> - <Route path="/tag/:tagName" element={<FeedItems />} /> - </Routes> - </MemoryRouter> - ); + it('fetches items by tag in FeedItems', async () => { + const mockItems = [ + { _id: 101, title: 'Tag Item 1', url: 'http://example.com/1', feed_title: 'Feed 1' }, + ]; - await waitFor(() => { - // expect(screen.getByText('Tag: Tech')).toBeInTheDocument(); - expect(screen.getByText('Tag Item 1')).toBeInTheDocument(); + (global.fetch as any).mockImplementation((url: string) => { + if (url.includes('/api/stream')) { + return Promise.resolve({ + ok: true, + json: async () => mockItems, }); + } + return Promise.reject(new Error(`Unknown URL: ${url}`)); + }); - const params = new URLSearchParams(); - params.append('tag', 'Tech'); - params.append('read_filter', 'unread'); - expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`); + render( + <MemoryRouter initialEntries={['/tag/Tech']}> + <Routes> + <Route path="/tag/:tagName" element={<FeedItems />} /> + </Routes> + </MemoryRouter> + ); + + await waitFor(() => { + // expect(screen.getByText('Tag: Tech')).toBeInTheDocument(); + expect(screen.getByText('Tag Item 1')).toBeInTheDocument(); }); + + const params = new URLSearchParams(); + params.append('tag', 'Tech'); + params.append('read_filter', 'unread'); + expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`); + }); }); diff --git a/frontend/src/index.css b/frontend/src/index.css index aca76c6..209a30a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -16,24 +16,18 @@ h5, :root { line-height: 1.5; - font-weight: 400; font-size: 18px; /* Light Mode Defaults */ --bg-color: #ffffff; --text-color: rgba(0, 0, 0, 0.87); --sidebar-bg: #ccc; - --link-color: #0000EE; + --link-color: #0000ee; /* Standard blue link */ color-scheme: light dark; color: var(--text-color); background-color: var(--bg-color); - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; } @media (prefers-color-scheme: dark) { @@ -88,7 +82,7 @@ button { border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; - font-weight: 500; + font-weight: bold; font-family: inherit; background-color: #1a1a1a; cursor: pointer; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..df655ea 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './index.css'; +import App from './App.tsx'; createRoot(document.getElementById('root')!).render( <StrictMode> <App /> - </StrictMode>, -) + </StrictMode> +); diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 052d18e..5781184 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -2,39 +2,39 @@ import '@testing-library/jest-dom'; // Mock IntersectionObserver class IntersectionObserver { - readonly root: Element | null = null; - readonly rootMargin: string = ''; - readonly thresholds: ReadonlyArray<number> = []; + readonly root: Element | null = null; + readonly rootMargin: string = ''; + readonly thresholds: ReadonlyArray<number> = []; - constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { - // nothing - } + constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { + // nothing + } - observe(_target: Element): void { - // nothing - } + observe(_target: Element): void { + // nothing + } - unobserve(_target: Element): void { - // nothing - } + unobserve(_target: Element): void { + // nothing + } - disconnect(): void { - // nothing - } + disconnect(): void { + // nothing + } - takeRecords(): IntersectionObserverEntry[] { - return []; - } + takeRecords(): IntersectionObserverEntry[] { + return []; + } } Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, + writable: true, + configurable: true, + value: IntersectionObserver, }); Object.defineProperty(globalThis, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, + writable: true, + configurable: true, + value: IntersectionObserver, }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4c1110f..1feea1f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,24 +1,24 @@ export interface Feed { - _id: number; - url: string; - web_url: string; - title: string; - category: string; + _id: number; + url: string; + web_url: string; + title: string; + category: string; } export interface Item { - _id: number; - feed_id: number; - title: string; - url: string; - description: string; - publish_date: string; - read: boolean; - starred: boolean; - full_content?: string; - header_image?: string; - feed_title?: string; + _id: number; + feed_id: number; + title: string; + url: string; + description: string; + publish_date: string; + read: boolean; + starred: boolean; + full_content?: string; + header_image?: string; + feed_title?: string; } export interface Category { - title: string; + title: string; } |
