aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 06:58:30 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 06:58:30 -0800
commitbd2508211760edbc1bad1d515587d08fd2ec99c9 (patch)
treeb2e17d9b7a9c460dd808962394e956f0c400d943 /frontend/src
parent3ba71500bc2d60a00ca81b9439305029670f4d52 (diff)
downloadneko-bd2508211760edbc1bad1d515587d08fd2ec99c9.tar.gz
neko-bd2508211760edbc1bad1d515587d08fd2ec99c9.tar.bz2
neko-bd2508211760edbc1bad1d515587d08fd2ec99c9.zip
Implement Item Interactions (read/star) with tests
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/components/FeedItem.css81
-rw-r--r--frontend/src/components/FeedItem.test.tsx91
-rw-r--r--frontend/src/components/FeedItem.tsx89
-rw-r--r--frontend/src/components/FeedItems.css46
-rw-r--r--frontend/src/components/FeedItems.tsx14
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>
)}