aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-14 08:58:38 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-14 08:58:38 -0800
commite3c379d069ffa9661561d25cdbf2f5894a2f8ee8 (patch)
tree24d0e9f5610dd9c8f873c5b78e6bc1c88d32840a /frontend/src
parent4b06155fbde91a1bef6361ef36efb28789861928 (diff)
downloadneko-e3c379d069ffa9661561d25cdbf2f5894a2f8ee8.tar.gz
neko-e3c379d069ffa9661561d25cdbf2f5894a2f8ee8.tar.bz2
neko-e3c379d069ffa9661561d25cdbf2f5894a2f8ee8.zip
Refactor: project structure, implement dependency injection, and align v2 UI with v1
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.css4
-rw-r--r--frontend/src/App.test.tsx79
-rw-r--r--frontend/src/App.tsx42
-rw-r--r--frontend/src/components/FeedItem.css124
-rw-r--r--frontend/src/components/FeedItem.test.tsx103
-rw-r--r--frontend/src/components/FeedItem.tsx146
-rw-r--r--frontend/src/components/FeedItems.css23
-rw-r--r--frontend/src/components/FeedItems.test.tsx431
-rw-r--r--frontend/src/components/FeedItems.tsx423
-rw-r--r--frontend/src/components/FeedList.css182
-rw-r--r--frontend/src/components/FeedList.test.tsx200
-rw-r--r--frontend/src/components/FeedList.tsx246
-rw-r--r--frontend/src/components/Login.css4
-rw-r--r--frontend/src/components/Login.test.tsx101
-rw-r--r--frontend/src/components/Login.tsx84
-rw-r--r--frontend/src/components/Settings.css87
-rw-r--r--frontend/src/components/Settings.test.tsx145
-rw-r--r--frontend/src/components/Settings.tsx214
-rw-r--r--frontend/src/components/TagView.test.tsx128
-rw-r--r--frontend/src/index.css10
-rw-r--r--frontend/src/main.tsx12
-rw-r--r--frontend/src/setupTests.ts48
-rw-r--r--frontend/src/types.ts34
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;
}