diff options
Diffstat (limited to 'frontend/src/components')
| -rw-r--r-- | frontend/src/components/FeedItem.css | 81 | ||||
| -rw-r--r-- | frontend/src/components/FeedItem.test.tsx | 91 | ||||
| -rw-r--r-- | frontend/src/components/FeedItem.tsx | 89 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.css | 46 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 14 |
5 files changed, 263 insertions, 58 deletions
diff --git a/frontend/src/components/FeedItem.css b/frontend/src/components/FeedItem.css new file mode 100644 index 0000000..916ee42 --- /dev/null +++ b/frontend/src/components/FeedItem.css @@ -0,0 +1,81 @@ +.feed-item { + border-bottom: 1px solid #f0f0f0; + padding: 1rem 0; + list-style: none; + /* Ensure no bullets if used in ul */ +} + +.feed-item.read .item-title { + color: #888; + font-weight: normal; +} + +.feed-item.unread .item-title { + font-weight: bold; +} + +.item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.item-title { + font-size: 1.2rem; + text-decoration: none; + color: #333; + display: block; + margin-bottom: 0.5rem; + flex: 1; + /* Take up remaining space */ +} + +.item-title:hover { + text-decoration: underline; + color: #007bff; +} + +.item-actions { + display: flex; + gap: 0.5rem; + margin-left: 1rem; +} + +.action-btn { + background: none; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; + padding: 2px 6px; + font-size: 1rem; + line-height: 1; +} + +.action-btn:hover { + background-color: #f8f9fa; + border-color: #ccc; +} + +.action-btn.is-starred { + color: gold; + border-color: gold; +} + +.item-meta { + font-size: 0.85rem; + color: #666; + margin-bottom: 0.5rem; +} + +.item-description { + color: #444; + line-height: 1.5; + font-size: 0.95rem; +} + +.item-description img { + max-width: 100%; + height: auto; + display: block; + margin: 1rem 0; +}
\ No newline at end of file diff --git a/frontend/src/components/FeedItem.test.tsx b/frontend/src/components/FeedItem.test.tsx new file mode 100644 index 0000000..d46afaf --- /dev/null +++ b/frontend/src/components/FeedItem.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +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' +}; + +describe('FeedItem Component', () => { + 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('toggles read status', async () => { + (global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + render(<FeedItem item={mockItem} />); + + const readBtn = screen.getByTitle('Mark as read'); + fireEvent.click(readBtn); + + // Optimistic update + expect(await screen.findByTitle('Mark as unread')).toBeInTheDocument(); + + expect(global.fetch).toHaveBeenCalledWith('/api/item/1', expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + _id: 1, + read: true, + starred: false + }) + })); + }); + + it('toggles star status', async () => { + (global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + render(<FeedItem item={mockItem} />); + + const starBtn = screen.getByTitle('Star'); + fireEvent.click(starBtn); + + // Optimistic update + expect(await screen.findByTitle('Unstar')).toBeInTheDocument(); + + expect(global.fetch).toHaveBeenCalledWith('/api/item/1', expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + _id: 1, + read: false, + starred: true + }) + })); + }); + + it('reverts optimistic update on failure', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('API Error')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + + render(<FeedItem item={mockItem} />); + + const readBtn = screen.getByTitle('Mark as read'); + fireEvent.click(readBtn); + + // Should revert to unread + await waitFor(() => { + expect(screen.getByTitle('Mark as read')).toBeInTheDocument(); + }); + + consoleSpy.mockRestore(); + }); +}); diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx new file mode 100644 index 0000000..aa0cea8 --- /dev/null +++ b/frontend/src/components/FeedItem.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import type { Item } from '../types'; +import './FeedItem.css'; + +interface FeedItemProps { + item: Item; +} + +export default function FeedItem({ item: initialItem }: FeedItemProps) { + const [item, setItem] = useState(initialItem); + const [loading, setLoading] = useState(false); + + const toggleRead = () => { + updateItem({ ...item, read: !item.read }); + }; + + const toggleStar = () => { + updateItem({ ...item, starred: !item.starred }); + }; + + 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); + }); + }; + + 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> + <div className="item-actions"> + <button + onClick={toggleRead} + className={`action-btn ${item.read ? 'is-read' : 'is-unread'}`} + title={item.read ? "Mark as unread" : "Mark as read"} + > + {item.read ? '📖' : 'uo'} + </button> + <button + onClick={toggleStar} + className={`action-btn ${item.starred ? 'is-starred' : 'is-unstarred'}`} + title={item.starred ? "Unstar" : "Star"} + > + {item.starred ? '★' : '☆'} + </button> + </div> + </div> + <div className="item-meta"> + <span className="item-date">{new Date(item.publish_date).toLocaleDateString()}</span> + {item.feed_title && <span className="item-feed"> - {item.feed_title}</span>} + </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 a057a40..795156d 100644 --- a/frontend/src/components/FeedItems.css +++ b/frontend/src/components/FeedItems.css @@ -11,50 +11,4 @@ .item-list { list-style: none; padding: 0; -} - -.item { - border-bottom: 1px solid #f0f0f0; - padding: 1rem 0; -} - -.item.read .item-title { - color: #888; - font-weight: normal; -} - -.item.unread .item-title { - font-weight: bold; -} - -.item-title { - font-size: 1.2rem; - text-decoration: none; - color: #333; - display: block; - margin-bottom: 0.5rem; -} - -.item-title:hover { - text-decoration: underline; - color: #007bff; -} - -.item-meta { - font-size: 0.85rem; - color: #666; - margin-bottom: 0.5rem; -} - -.item-description { - color: #444; - line-height: 1.5; - font-size: 0.95rem; -} - -.item-description img { - max-width: 100%; - height: auto; - display: block; - margin: 1rem 0; }
\ No newline at end of file diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index 048bed7..e6f0a84 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import type { Item } from '../types'; +import FeedItem from './FeedItem'; import './FeedItems.css'; export default function FeedItems() { @@ -46,18 +47,7 @@ export default function FeedItems() { ) : ( <ul className="item-list"> {items.map((item) => ( - <li key={item._id} className={`item ${item.read ? 'read' : 'unread'}`}> - <a href={item.url} target="_blank" rel="noopener noreferrer" className="item-title"> - {item.title || '(No Title)'} - </a> - <div className="item-meta"> - <span className="item-date">{new Date(item.publish_date).toLocaleDateString()}</span> - {item.feed_title && <span className="item-feed"> - {item.feed_title}</span>} - </div> - {item.description && ( - <div className="item-description" dangerouslySetInnerHTML={{ __html: item.description }} /> - )} - </li> + <FeedItem key={item._id} item={item} /> ))} </ul> )} |
