diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/App.tsx | 12 | ||||
| -rw-r--r-- | frontend/src/components/Settings.css | 83 | ||||
| -rw-r--r-- | frontend/src/components/Settings.test.tsx | 92 | ||||
| -rw-r--r-- | frontend/src/components/Settings.tsx | 121 |
4 files changed, 308 insertions, 0 deletions
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c9d555..8c7be19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,7 @@ function RequireAuth({ children }: { children: React.ReactElement }) { import FeedList from './components/FeedList'; import FeedItems from './components/FeedItems'; +import Settings from './components/Settings'; function Dashboard() { return ( @@ -40,6 +41,16 @@ function 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={() => { fetch('/api/logout', { method: 'POST' }) .then(() => window.location.href = '/login/'); @@ -55,6 +66,7 @@ function Dashboard() { <main className="dashboard-main"> <Routes> <Route path="/feed/:feedId" element={<FeedItems />} /> + <Route path="/settings" element={<Settings />} /> <Route path="/" element={<p>Select a feed to view items.</p>} /> </Routes> </main> diff --git a/frontend/src/components/Settings.css b/frontend/src/components/Settings.css new file mode 100644 index 0000000..4065e88 --- /dev/null +++ b/frontend/src/components/Settings.css @@ -0,0 +1,83 @@ +.settings-page { + 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; +} + +.add-feed-form { + display: flex; + gap: 1rem; +} + +.feed-input { + flex: 1; + padding: 0.5rem; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 1rem; +} + +.error-message { + color: #d32f2f; + margin-top: 1rem; +} + +.settings-feed-list { + 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; +} + +.settings-feed-item:last-child { + border-bottom: none; +} + +.feed-info { + display: flex; + flex-direction: column; +} + +.feed-title { + font-weight: bold; + font-size: 1.1rem; +} + +.feed-url { + color: #666; + font-size: 0.9rem; +} + +.delete-btn { + background: #ff5252; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; +} + +.delete-btn:hover { + background: #ff1744; +} + +.delete-btn:disabled { + background: #ffcdd2; + cursor: not-allowed; +}
\ No newline at end of file diff --git a/frontend/src/components/Settings.test.tsx b/frontend/src/components/Settings.test.tsx new file mode 100644 index 0000000..a15192d --- /dev/null +++ b/frontend/src/components/Settings.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +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); + }); + + 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, + }); + + render(<Settings />); + + 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(); + }); + + 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); + + 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(); + }); + }); + + 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 + + render(<Settings />); + + await waitFor(() => { + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + expect(screen.getByText('Tech News')).toBeInTheDocument(); + }); + + 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(); + }); + }); +}); diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx new file mode 100644 index 0000000..def8ffe --- /dev/null +++ b/frontend/src/components/Settings.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react'; +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); + + 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 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; + + 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); + }); + }; + + 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> + ); +} |
