aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 07:13:18 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 07:13:18 -0800
commita5cd9538b0db731a0d0e10e58804ef8ad32211b7 (patch)
tree0b46b0f43965de9df03b023abc40d869224e38d8 /frontend/src/components
parente31b68197ec16d2805ec14c2bf532a03f4739e92 (diff)
downloadneko-a5cd9538b0db731a0d0e10e58804ef8ad32211b7.tar.gz
neko-a5cd9538b0db731a0d0e10e58804ef8ad32211b7.tar.bz2
neko-a5cd9538b0db731a0d0e10e58804ef8ad32211b7.zip
Implement Frontend Settings with tests
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/Settings.css83
-rw-r--r--frontend/src/components/Settings.test.tsx92
-rw-r--r--frontend/src/components/Settings.tsx121
3 files changed, 296 insertions, 0 deletions
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>
+ );
+}