aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 07:46:58 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 09:38:38 -0800
commit23a48e1d498680be769e931f46ddb1fd44f38d1a (patch)
tree54bb607e19b3eec0e5c932e6748d9ca6304d4b17 /frontend/src
parenta5cd9538b0db731a0d0e10e58804ef8ad32211b7 (diff)
downloadneko-23a48e1d498680be769e931f46ddb1fd44f38d1a.tar.gz
neko-23a48e1d498680be769e931f46ddb1fd44f38d1a.tar.bz2
neko-23a48e1d498680be769e931f46ddb1fd44f38d1a.zip
Implement Tag View and fix tests
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/App.test.tsx11
-rw-r--r--frontend/src/App.tsx16
-rw-r--r--frontend/src/components/FeedItems.tsx16
-rw-r--r--frontend/src/components/FeedList.css36
-rw-r--r--frontend/src/components/FeedList.test.tsx39
-rw-r--r--frontend/src/components/FeedList.tsx66
-rw-r--r--frontend/src/components/TagView.test.tsx81
-rw-r--r--frontend/src/types.ts3
8 files changed, 215 insertions, 53 deletions
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 5614d7d..d0c31fd 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -20,9 +20,12 @@ describe('App', () => {
});
it('renders dashboard when authenticated', async () => {
- (global.fetch as any)
- .mockResolvedValueOnce({ ok: true }) // /api/auth
- .mockResolvedValueOnce({ ok: true, json: async () => [] }); // /api/feed/
+ (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 />);
@@ -47,7 +50,7 @@ describe('App', () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith('/api/logout', expect.objectContaining({ method: 'POST' }));
- expect(window.location.href).toBe('/login/');
+ expect(window.location.href).toBe('/v2/login');
});
});
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8c7be19..09148d6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
-import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './components/Login';
import './App.css';
@@ -36,24 +36,17 @@ import FeedItems from './components/FeedItems';
import Settings from './components/Settings';
function Dashboard() {
+ const navigate = useNavigate();
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>Neko Reader</h1>
<nav>
- <a href="/settings" onClick={(e) => {
- e.preventDefault();
- window.history.pushState({}, '', '/settings');
- // Quick hack for navigation without full router link if inside Router context,
- // but here we are inside BrowserRouter so we should use Link or just simple navigation
- // actually let's just use a real Link if we can, but we need import.
- // For now, let's just rely on the Router catching the URL change if we use proper Link
- // or just a button that navigates.
- }} style={{ color: 'white', marginRight: '1rem' }}>Settings</a>
+ <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 = '/login/');
+ .then(() => window.location.href = '/v2/login');
}} className="logout-btn">
Logout
</button>
@@ -66,6 +59,7 @@ function Dashboard() {
<main className="dashboard-main">
<Routes>
<Route path="/feed/:feedId" element={<FeedItems />} />
+ <Route path="/tag/:tagName" element={<FeedItems />} />
<Route path="/settings" element={<Settings />} />
<Route path="/" element={<p>Select a feed to view items.</p>} />
</Routes>
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
index e6f0a84..01a24fc 100644
--- a/frontend/src/components/FeedItems.tsx
+++ b/frontend/src/components/FeedItems.tsx
@@ -5,7 +5,7 @@ import FeedItem from './FeedItem';
import './FeedItems.css';
export default function FeedItems() {
- const { feedId } = useParams<{ feedId: string }>();
+ const { feedId, tagName } = useParams<{ feedId: string; tagName: string }>();
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -14,9 +14,12 @@ export default function FeedItems() {
setLoading(true);
setError('');
- const url = feedId
- ? `/api/stream?feed_id=${feedId}`
- : '/api/stream'; // Default or "all" view? For now let's assume we need a feedId or handle "all" logic later
+ let url = '/api/stream';
+ if (feedId) {
+ url = `/api/stream?feed_id=${feedId}`;
+ } else if (tagName) {
+ url = `/api/stream?tag=${encodeURIComponent(tagName)}`;
+ }
fetch(url)
.then((res) => {
@@ -33,15 +36,14 @@ export default function FeedItems() {
setError(err.message);
setLoading(false);
});
- }, [feedId]);
+ }, [feedId, tagName]);
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">
- <h2>Items</h2>
- {/* TODO: Add Feed Title here if possible, maybe pass from location state or fetch feed details */}
+ <h2>{tagName ? `Tag: ${tagName}` : 'Items'}</h2>
{items.length === 0 ? (
<p>No items found.</p>
) : (
diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css
index f35ed59..485fab3 100644
--- a/frontend/src/components/FeedList.css
+++ b/frontend/src/components/FeedList.css
@@ -45,4 +45,38 @@
border-radius: 4px;
font-size: 0.8rem;
color: #666;
-} \ No newline at end of file
+}
+.tag-section {
+ margin-top: 2rem;
+ border-top: 1px solid #eee;
+ padding-top: 1rem;
+}
+
+.tag-list-items {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.tag-item {
+ display: inline-block;
+}
+
+.tag-link {
+ display: block;
+ padding: 0.3rem 0.6rem;
+ background: #e9ecef;
+ border-radius: 12px;
+ text-decoration: none;
+ color: #333;
+ font-size: 0.9rem;
+ transition: background-color 0.2s;
+}
+
+.tag-link:hover {
+ background: #dde2e6;
+ color: #000;
+}
diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx
index 92ff345..eba9b88 100644
--- a/frontend/src/components/FeedList.test.tsx
+++ b/frontend/src/components/FeedList.test.tsx
@@ -28,9 +28,20 @@ describe('FeedList Component', () => {
{ _id: 2, title: 'Feed Two', url: 'http://test.com/rss', web_url: 'http://test.com', category: 'News' },
];
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: async () => mockFeeds,
+ (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(
@@ -42,12 +53,13 @@ describe('FeedList Component', () => {
await waitFor(() => {
expect(screen.getByText('Feed One')).toBeInTheDocument();
expect(screen.getByText('Feed Two')).toBeInTheDocument();
- expect(screen.getByText('Tech')).toBeInTheDocument();
+ const techElements = screen.getAllByText('Tech');
+ expect(techElements.length).toBeGreaterThan(0);
});
});
it('handles fetch error', async () => {
- (global.fetch as any).mockRejectedValueOnce(new Error('API Error'));
+ (global.fetch as any).mockImplementation(() => Promise.reject(new Error('API Error')));
render(
<BrowserRouter>
@@ -61,9 +73,20 @@ describe('FeedList Component', () => {
});
it('handles empty feed list', async () => {
- (global.fetch as any).mockResolvedValueOnce({
- ok: true,
- json: 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(
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index f913293..f17fdc7 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -1,23 +1,28 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
-import type { Feed } from '../types';
+import type { Feed, Category } from '../types';
import './FeedList.css';
export default function FeedList() {
const [feeds, setFeeds] = useState<Feed[]>([]);
+ const [tags, setTags] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
- fetch('/api/feed/')
- .then((res) => {
- if (!res.ok) {
- throw new Error('Failed to fetch feeds');
- }
+ 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((data) => {
- setFeeds(data);
+ ])
+ .then(([feedsData, tagsData]) => {
+ setFeeds(feedsData);
+ setTags(tagsData);
setLoading(false);
})
.catch((err) => {
@@ -31,20 +36,37 @@ export default function FeedList() {
return (
<div className="feed-list">
- <h2>Feeds</h2>
- {feeds.length === 0 ? (
- <p>No feeds found.</p>
- ) : (
- <ul className="feed-list-items">
- {feeds.map((feed) => (
- <li key={feed._id} className="feed-item">
- <Link to={`/feed/${feed._id}`} className="feed-title">
- {feed.title || feed.url}
- </Link>
- {feed.category && <span className="feed-category">{feed.category}</span>}
- </li>
- ))}
- </ul>
+ <div className="feed-section">
+ <h2>Feeds</h2>
+ {feeds.length === 0 ? (
+ <p>No feeds found.</p>
+ ) : (
+ <ul className="feed-list-items">
+ {feeds.map((feed) => (
+ <li key={feed._id} className="feed-item">
+ <Link to={`/feed/${feed._id}`} className="feed-title">
+ {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">
+ {tag.title}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </div>
)}
</div>
);
diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx
new file mode 100644
index 0000000..8a724cd
--- /dev/null
+++ b/frontend/src/components/TagView.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import FeedList from './FeedList';
+import FeedItems from './FeedItems';
+
+describe('Tag View Integration', () => {
+ 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' }];
+
+ (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}`));
+ });
+
+ render(
+ <MemoryRouter>
+ <FeedList />
+ </MemoryRouter>
+ );
+
+ 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');
+ });
+
+ 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' }
+ ];
+
+ (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}`));
+ });
+
+ 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();
+ });
+
+ expect(global.fetch).toHaveBeenCalledWith('/api/stream?tag=Tech');
+ });
+});
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 872e608..4c1110f 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -19,3 +19,6 @@ export interface Item {
header_image?: string;
feed_title?: string;
}
+export interface Category {
+ title: string;
+}