aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-16 19:37:50 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-16 19:40:10 -0800
commitcba29e6aae637b04ff6eaf28f74bc15b6242b9ea (patch)
tree226753a3fcd18a6d45089dafcb1ee72557140aa8 /frontend/src/components
parentcb6d0c9e330c27ff85ff065c2ea6dd1a756cbf6d (diff)
downloadneko-cba29e6aae637b04ff6eaf28f74bc15b6242b9ea.tar.gz
neko-cba29e6aae637b04ff6eaf28f74bc15b6242b9ea.tar.bz2
neko-cba29e6aae637b04ff6eaf28f74bc15b6242b9ea.zip
Remove legacy V2 React frontend and update build/test scripts to focus on Vanilla JS (V3)
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/FeedItem.css158
-rw-r--r--frontend/src/components/FeedItem.test.tsx80
-rw-r--r--frontend/src/components/FeedItem.tsx90
-rw-r--r--frontend/src/components/FeedItems.css23
-rw-r--r--frontend/src/components/FeedItems.test.tsx250
-rw-r--r--frontend/src/components/FeedItems.tsx313
-rw-r--r--frontend/src/components/FeedList.css225
-rw-r--r--frontend/src/components/FeedList.test.tsx230
-rw-r--r--frontend/src/components/FeedList.tsx279
-rw-r--r--frontend/src/components/FeedListVariants.css342
-rw-r--r--frontend/src/components/Login.css63
-rw-r--r--frontend/src/components/Login.test.tsx93
-rw-r--r--frontend/src/components/Login.tsx67
-rw-r--r--frontend/src/components/Settings.css240
-rw-r--r--frontend/src/components/Settings.test.tsx207
-rw-r--r--frontend/src/components/Settings.tsx236
-rw-r--r--frontend/src/components/TagView.test.tsx93
17 files changed, 0 insertions, 2989 deletions
diff --git a/frontend/src/components/FeedItem.css b/frontend/src/components/FeedItem.css
deleted file mode 100644
index 876fc66..0000000
--- a/frontend/src/components/FeedItem.css
+++ /dev/null
@@ -1,158 +0,0 @@
-.feed-item {
- padding: 1rem;
- margin-top: 5rem;
- list-style: none;
- border-bottom: none;
-}
-
-/* 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;
-}
-
-.item-title {
- font-family: var(--font-heading);
- 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);
-}
-
-.item-actions {
- 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;
-}
-
-.star-btn.is-starred {
- color: blue;
-}
-
-.star-btn.is-unstarred {
- color: var(--text-color);
- opacity: 0.3;
-}
-
-.star-btn:hover {
- color: blue;
-}
-
-.action-btn {
- background: var(--sidebar-bg);
- border: 1px solid var(--border-color, #ccc);
- cursor: pointer;
- padding: 2px 6px;
- font-size: 1rem;
- color: blue;
- font-weight: bold;
-}
-
-.action-btn:hover {
- background-color: #eee;
-}
-
-.dateline {
- margin-top: 0;
- font-weight: normal;
- font-size: 0.75em;
- color: #ccc;
- margin-bottom: 1rem;
-}
-
-.dateline a {
- color: #ccc;
- text-decoration: none;
-}
-
-.item-description {
- color: var(--text-color);
- line-height: 1.5;
- font-size: 1rem;
- margin-top: 1rem;
- overflow-wrap: break-word;
- word-break: break-word;
-}
-
-.item-description table,
-.item-description pre,
-.item-description code {
- max-width: 100%;
- overflow-x: auto;
- display: block;
-}
-
-.item-description img {
- max-width: 100%;
- height: auto;
- display: block;
- margin: 1rem 0;
-}
-
-.item-description blockquote {
- padding: 1rem 1rem 0 1rem;
- border-left: 4px solid var(--sidebar-bg);
- color: var(--text-color);
- opacity: 0.8;
- margin-left: 0;
-}
-
-.scrape-btn {
- background: var(--bg-color);
- border: 1px solid var(--border-color, #ccc);
- color: blue;
- cursor: pointer;
- font-family: var(--font-heading);
- font-weight: bold;
- font-size: 0.8rem;
- padding: 2px 6px;
- margin-left: 0.5rem;
-}
-
-.scrape-btn:hover {
- background: var(--sidebar-bg);
-}
-
-@media (max-width: 768px) {
- .feed-item {
- margin-top: 2rem;
- padding: 0.5rem;
- }
-
- .item-title {
- font-size: 1.4rem;
- word-break: break-word;
- }
-
- .item-header {
- flex-direction: column;
- gap: 0.5rem;
- }
-
- .item-actions {
- margin-left: 0;
- margin-bottom: 0.5rem;
- }
-} \ No newline at end of file
diff --git a/frontend/src/components/FeedItem.test.tsx b/frontend/src/components/FeedItem.test.tsx
deleted file mode 100644
index ab2ca45..0000000
--- a/frontend/src/components/FeedItem.test.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react';
-import '@testing-library/jest-dom';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import FeedItem from './FeedItem';
-import type { Item } from '../types';
-
-const mockItem: Item = {
- _id: 1,
- feed_id: 101,
- title: 'Test Item',
- url: 'http://example.com/item',
- description: '<p>Description</p>',
- publish_date: '2023-01-01',
- read: false,
- starred: false,
- feed_title: 'Test Feed',
-};
-
-describe('FeedItem Component', () => {
- beforeEach(() => {
- vi.resetAllMocks();
- global.fetch = vi.fn();
- });
-
- it('renders item details', () => {
- render(<FeedItem item={mockItem} />);
- expect(screen.getByText('Test Item')).toBeInTheDocument();
- expect(screen.getByText(/Test Feed/)).toBeInTheDocument();
- });
-
- it('calls onToggleStar when star clicked', () => {
- const onToggleStar = vi.fn();
- render(<FeedItem item={mockItem} onToggleStar={onToggleStar} />);
-
- const starBtn = screen.getByTitle('Star');
- fireEvent.click(starBtn);
-
- expect(onToggleStar).toHaveBeenCalledWith(mockItem);
- });
-
- it('updates styling when read state changes', () => {
- const { rerender } = render(<FeedItem item={{ ...mockItem, read: false }} />);
- const link = screen.getByText('Test Item');
- const listItem = link.closest('li');
- expect(listItem).toHaveClass('unread');
- expect(listItem).not.toHaveClass('read');
-
- rerender(<FeedItem item={{ ...mockItem, read: true }} />);
- expect(listItem).toHaveClass('read');
- expect(listItem).not.toHaveClass('unread');
- });
-
- it('loads full content and calls onUpdate', async () => {
- const onUpdate = vi.fn();
- vi.mocked(global.fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => ({ full_content: '<p>Full Content Loaded</p>' }),
- } as Response);
-
- const { rerender } = render(<FeedItem item={mockItem} onUpdate={onUpdate} />);
-
- const scrapeBtn = screen.getByTitle('Load Full Content');
- fireEvent.click(scrapeBtn);
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith('/api/item/1', expect.anything());
- });
-
- await waitFor(() => {
- expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
- full_content: '<p>Full Content Loaded</p>'
- }));
- });
-
- // Simulate parent updating prop
- rerender(<FeedItem item={{ ...mockItem, full_content: '<p>Full Content Loaded</p>' }} onUpdate={onUpdate} />);
- expect(screen.getByText('Full Content Loaded')).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/components/FeedItem.tsx b/frontend/src/components/FeedItem.tsx
deleted file mode 100644
index 865c080..0000000
--- a/frontend/src/components/FeedItem.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useState, memo } from 'react';
-import type { Item } from '../types';
-import './FeedItem.css';
-
-import { apiFetch } from '../utils';
-
-interface FeedItemProps {
- item: Item;
- onToggleStar?: (item: Item) => void;
- onUpdate?: (item: Item) => void;
-}
-
-const FeedItem = memo(function FeedItem({ item, onToggleStar, onUpdate }: FeedItemProps) {
- const [loading, setLoading] = useState(false);
-
- // We rely on props.item for data.
- // If we fetch full content, we notify the parent via onUpdate.
-
- const handleToggleStar = (e: React.MouseEvent) => {
- e.stopPropagation();
- if (onToggleStar) {
- onToggleStar(item);
- } else {
- // Fallback if no handler passed (backward compat or isolated usage)
- // But really we should rely on parent.
- // For now, let's keep the optimistic local update logic if we were standalone,
- // but since we are optimizing, we assume parent handles it.
- }
- };
-
- const loadFullContent = (e: React.MouseEvent) => {
- e.stopPropagation();
- setLoading(true);
- apiFetch(`/api/item/${item._id}`)
- .then((res) => {
- if (!res.ok) throw new Error('Failed to fetch full content');
- return res.json();
- })
- .then((data) => {
- // Merge the new data (full_content) into the item and notify parent
- const newItem = { ...item, ...data };
- if (onUpdate) {
- onUpdate(newItem);
- }
- setLoading(false);
- })
- .catch((err) => {
- console.error('Error fetching full content:', err);
- 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={handleToggleStar}
- 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' }}>
- {!item.full_content && (
- <button onClick={loadFullContent} className="scrape-btn" title="Load Full Content">
- text
- </button>
- )}
- </div>
- </div>
- {(item.full_content || item.description) && (
- <div
- className="item-description"
- dangerouslySetInnerHTML={{ __html: item.full_content || item.description }}
- />
- )}
- </li>
- );
-});
-
-export default FeedItem;
diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css
deleted file mode 100644
index 7154ac2..0000000
--- a/frontend/src/components/FeedItems.css
+++ /dev/null
@@ -1,23 +0,0 @@
-.feed-items {
- padding: 1rem 0;
- /* Removing horizontal padding to avoid double-padding with FeedItem */
-}
-
-.feed-items h2 {
- margin-top: 0;
- border-bottom: 2px solid var(--border-color);
- padding-bottom: 0.5rem;
-}
-
-.item-list {
- list-style: none;
- padding: 0;
-}
-
-.loading-more {
- 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
deleted file mode 100644
index 1a002d8..0000000
--- a/frontend/src/components/FeedItems.test.tsx
+++ /dev/null
@@ -1,250 +0,0 @@
-import React from 'react';
-import '@testing-library/jest-dom';
-import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
-import { MemoryRouter, Route, Routes } from 'react-router-dom';
-import { describe, it, expect, vi, beforeEach, afterEach } 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();
- }
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- window.IntersectionObserver = MockIntersectionObserver as any;
- });
-
- it('renders loading state', () => {
- vi.mocked(global.fetch).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,
- },
- ];
-
- vi.mocked(global.fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => mockItems,
- } as Response);
-
- 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()}`, expect.anything());
- });
-
-
-
- afterEach(() => {
- vi.useRealTimers();
- vi.restoreAllMocks();
- });
-
- it('marks items as read when scrolled past', async () => {
- const mockItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }];
- vi.mocked(global.fetch).mockResolvedValue({
- ok: true,
- json: async () => mockItems,
- } as Response);
-
- // Mock getBoundingClientRect
- const getBoundingClientRectMock = vi.spyOn(Element.prototype, 'getBoundingClientRect');
- getBoundingClientRectMock.mockImplementation(function (this: Element) {
- if (this.classList && this.classList.contains('dashboard-main')) {
- return {
- top: 0, bottom: 500, height: 500, left: 0, right: 1000, width: 1000, x: 0, y: 0,
- toJSON: () => { }
- } as DOMRect;
- }
- if (this.id && this.id.startsWith('item-')) {
- // Item top is -50 (above container top 0)
- return {
- top: -150, bottom: -50, height: 100, left: 0, right: 1000, width: 1000, x: 0, y: 0,
- toJSON: () => { }
- } as DOMRect;
- }
- return {
- top: 0, bottom: 0, height: 0, left: 0, right: 0, width: 0, x: 0, y: 0,
- toJSON: () => { }
- } as DOMRect;
- });
-
- render(
- <MemoryRouter>
- <div className="dashboard-main">
- <FeedItems />
- </div>
- </MemoryRouter>
- );
-
- // Initial load fetch
- await waitFor(() => {
- expect(screen.getByText('Item 1')).toBeVisible();
- });
-
- // Trigger scroll
- const container = document.querySelector('.dashboard-main');
- expect(container).not.toBeNull();
-
- act(() => {
- // Dispatch scroll event
- fireEvent.scroll(container!);
- });
-
- // Wait for throttle (500ms) + buffer
- await new Promise(r => setTimeout(r, 600));
-
- 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 }];
-
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => initialItems } as Response)
- .mockResolvedValueOnce({ ok: true, json: async () => moreItems } as Response);
-
- const observerCallbacks: IntersectionObserverCallback[] = [];
- class MockIntersectionObserver {
- constructor(callback: IntersectionObserverCallback) {
- observerCallbacks.push(callback);
- }
- observe = vi.fn();
- unobserve = vi.fn();
- disconnect = vi.fn();
- }
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- window.IntersectionObserver = MockIntersectionObserver as any;
-
- render(
- <MemoryRouter>
- <FeedItems />
- </MemoryRouter>
- );
-
- await waitFor(() => {
- expect(screen.getByText('Item 1')).toBeInTheDocument();
- });
-
- 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(() => {
- // Trigger all observers
- observerCallbacks.forEach(cb => cb([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');
- // Verify the second fetch call content
- expect(global.fetch).toHaveBeenCalledWith(
- expect.stringContaining('max_id=101'),
- expect.anything()
- );
- });
- });
-
- it('loads more items when pressing j on last item', async () => {
- const initialItems = [
- { _id: 103, title: 'Item 3', url: 'u3', read: true, starred: false },
- { _id: 102, title: 'Item 2', url: 'u2', read: true, starred: false },
- { _id: 101, title: 'Item 1', url: 'u1', read: true, starred: false },
- ];
- const moreItems = [
- { _id: 100, title: 'Item 0', url: 'u0', read: true, starred: false },
- ];
-
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => initialItems } as Response)
- .mockResolvedValueOnce({ ok: true, json: async () => moreItems } as Response);
-
- render(
- <MemoryRouter>
- <FeedItems />
- </MemoryRouter>
- );
-
- await waitFor(() => {
- expect(screen.getByText('Item 1')).toBeInTheDocument();
- });
-
- fireEvent.keyDown(window, { key: 'j' }); // index 0
- await waitFor(() => expect(document.getElementById('item-0')).toHaveAttribute('data-selected', 'true'));
-
- fireEvent.keyDown(window, { key: 'j' }); // index 1
- await waitFor(() => expect(document.getElementById('item-1')).toHaveAttribute('data-selected', 'true'));
-
- fireEvent.keyDown(window, { key: 'j' }); // index 2 (last item)
- await waitFor(() => expect(document.getElementById('item-2')).toHaveAttribute('data-selected', 'true'));
-
- await waitFor(() => {
- expect(screen.getByText('Item 0')).toBeInTheDocument();
- });
-
- // Check fetch call
- expect(global.fetch).toHaveBeenCalledWith(
- expect.stringContaining('max_id=101'),
- expect.anything()
- );
- });
-});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
deleted file mode 100644
index e38850a..0000000
--- a/frontend/src/components/FeedItems.tsx
+++ /dev/null
@@ -1,313 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import { useParams, useSearchParams } from 'react-router-dom';
-import type { Item } from '../types';
-import FeedItem from './FeedItem';
-import './FeedItems.css';
-import { apiFetch } from '../utils';
-
-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 itemsRef = useRef<Item[]>([]);
- const [loading, setLoading] = useState(true);
- const [loadingMore, setLoadingMore] = useState(false);
- const loadingMoreRef = useRef(loadingMore);
- const [hasMore, setHasMore] = useState(true);
- const hasMoreRef = useRef(hasMore);
- const [error, setError] = useState('');
- const [selectedIndex, setSelectedIndex] = useState(-1);
- const selectedIndexRef = useRef(selectedIndex);
-
- // Sync refs
- useEffect(() => {
- itemsRef.current = items;
- }, [items]);
-
- useEffect(() => {
- loadingMoreRef.current = loadingMore;
- }, [loadingMore]);
-
- useEffect(() => {
- hasMoreRef.current = hasMore;
- }, [hasMore]);
-
- useEffect(() => {
- selectedIndexRef.current = selectedIndex;
- }, [selectedIndex]);
-
- const fetchItems = useCallback((maxId?: string) => {
- if (maxId) {
- setLoadingMore(true);
- } else {
- setLoading(true);
- setItems([]);
- }
- setError('');
-
- let url = '/api/stream';
- const params = new URLSearchParams();
-
- if (feedId) {
- if (feedId.includes(',')) {
- params.append('feed_ids', feedId);
- } else {
- 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}`;
- }
-
- apiFetch(url)
- .then((res) => {
- if (!res.ok) {
- throw new Error('Failed to fetch items');
- }
- return res.json();
- })
- .then((data: Item[]) => {
- if (maxId) {
- setItems((prev) => {
- const existingIds = new Set(prev.map(i => i._id));
- const newItems = data.filter(i => !existingIds.has(i._id));
- return [...prev, ...newItems];
- });
- } else {
- setItems(data);
- }
- setHasMore(data.length > 0);
- setLoading(false);
- setLoadingMore(false);
- })
- .catch((err) => {
- setError(err.message);
- setLoading(false);
- setLoadingMore(false);
- });
- }, [feedId, tagName, filterFn, searchParams]);
-
- useEffect(() => {
- fetchItems();
- setSelectedIndex(-1);
- }, [fetchItems]);
-
-
- const scrollToItem = useCallback((index: number) => {
- const element = document.getElementById(`item-${index}`);
- if (element) {
- element.scrollIntoView({ behavior: 'auto', block: 'start' });
- }
- }, []);
-
- const markAsRead = useCallback((item: Item) => {
- const updatedItem = { ...item, read: true };
- setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
-
- apiFetch(`/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 = useCallback((item: Item) => {
- const updatedItem = { ...item, starred: !item.starred };
- setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
-
- apiFetch(`/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));
- }, []);
-
- const handleUpdateItem = useCallback((updatedItem: Item) => {
- setItems((prevItems) => prevItems.map((i) => (i._id === updatedItem._id ? updatedItem : i)));
- }, []);
-
-
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- const currentItems = itemsRef.current;
- if (currentItems.length === 0) return;
-
- if (e.key === 'j') {
- const nextIndex = Math.min(selectedIndexRef.current + 1, currentItems.length - 1);
- if (nextIndex !== selectedIndexRef.current) {
- selectedIndexRef.current = nextIndex;
- setSelectedIndex(nextIndex);
- const item = currentItems[nextIndex];
- if (!item.read) {
- markAsRead(item);
- }
- scrollToItem(nextIndex);
-
- // Trigger load more if needed
- if (nextIndex === currentItems.length - 1 && hasMoreRef.current && !loadingMoreRef.current) {
- fetchItems(String(currentItems[currentItems.length - 1]._id));
- }
- } else if (hasMoreRef.current && !loadingMoreRef.current) {
- // Already at last item, but more can be loaded
- fetchItems(String(currentItems[currentItems.length - 1]._id));
- }
- } else if (e.key === 'k') {
- const nextIndex = Math.max(selectedIndexRef.current - 1, 0);
- if (nextIndex !== selectedIndexRef.current) {
- selectedIndexRef.current = nextIndex;
- setSelectedIndex(nextIndex);
- scrollToItem(nextIndex);
- }
- } else if (e.key === 's') {
- if (selectedIndexRef.current >= 0 && selectedIndexRef.current < currentItems.length) {
- toggleStar(currentItems[selectedIndexRef.current]);
- }
- }
- };
-
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [markAsRead, scrollToItem, toggleStar, fetchItems]);
-
-
- // Scroll listener to mark items as read
- const sentinelObserverRef = useRef<IntersectionObserver | null>(null);
-
- const checkReadStatus = useCallback(() => {
- const container = document.querySelector('.dashboard-main');
- if (!container) return;
-
- const containerRect = container.getBoundingClientRect();
- const currentItems = itemsRef.current;
-
- currentItems.forEach((item, index) => {
- if (item.read) return;
-
- const el = document.getElementById(`item-${index}`);
- if (!el) return;
-
- const rect = el.getBoundingClientRect();
-
- // Mark as read if the bottom of the item is above the top of the container
- if (rect.bottom < containerRect.top) {
- markAsRead(item);
- }
- });
- }, [markAsRead]);
-
- // Setup scroll listener
- useEffect(() => {
- const container = document.querySelector('.dashboard-main');
- if (!container) return;
-
- let timeoutId: number | null = null;
- const onScroll = () => {
- if (timeoutId === null) {
- timeoutId = window.setTimeout(() => {
- checkReadStatus();
- timeoutId = null;
- }, 250);
- }
- };
-
- container.addEventListener('scroll', onScroll);
-
- // Initial check
- checkReadStatus();
-
- return () => {
- if (timeoutId) clearTimeout(timeoutId);
- container.removeEventListener('scroll', onScroll);
- };
- }, [checkReadStatus]);
-
- // Re-check when items change (e.g. initial load or load more)
- useEffect(() => {
- checkReadStatus();
- }, [items, checkReadStatus]);
-
-
-
- useEffect(() => {
- if (sentinelObserverRef.current) sentinelObserverRef.current.disconnect();
-
- sentinelObserverRef.current = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting && !loadingMoreRef.current && hasMoreRef.current && itemsRef.current.length > 0) {
- fetchItems(String(itemsRef.current[itemsRef.current.length - 1]._id));
- }
- });
- },
- { root: null, threshold: 0, rootMargin: '100px' }
- );
-
- const sentinel = document.getElementById('load-more-sentinel');
- if (sentinel) sentinelObserverRef.current.observe(sentinel);
-
- return () => sentinelObserverRef.current?.disconnect();
- }, [hasMore, fetchItems]); // removed loadingMore from deps, using ref inside. hasMore is needed for DOM presence.
-
-
- 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}
- onToggleStar={() => toggleStar(item)}
- onUpdate={handleUpdateItem}
- />
- </div>
- ))}
- {hasMore && (
- <li id="load-more-sentinel" className="loading-more">
- {loadingMore ? 'Loading more...' : ''}
- </li>
- )}
- </ul>
- )}
- </div>
- );
-}
diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css
deleted file mode 100644
index 38a324b..0000000
--- a/frontend/src/components/FeedList.css
+++ /dev/null
@@ -1,225 +0,0 @@
-.feed-list {
- padding: 1rem;
- font-family: var(--font-heading);
- color: #777;
- /* specific v1 color */
- font-size: 0.8rem;
- background: var(--sidebar-bg);
- min-height: 100%;
- flex: 1;
-}
-
-.feed-list h1.logo {
- font-size: 2rem;
- /* match v1 */
- margin: 0 0 1rem 0;
- line-height: 1;
- cursor: pointer;
- position: sticky;
- top: 0;
- background: var(--sidebar-bg);
- z-index: 10;
- padding-bottom: 0.5rem;
- color: var(--text-color);
-}
-
-/* Override logo color if necessary for themes */
-.theme-light .feed-list h1.logo {
- color: #333;
-}
-
-.theme-dark .feed-list h1.logo {
- color: #eee;
-}
-
-.search-section {
- margin-bottom: 1rem;
-}
-
-.search-input {
- width: 100%;
- padding: 0.25rem;
- border: 1px solid var(--border-color, #999);
- background: var(--bg-color);
- color: var(--text-color);
- font-size: 0.8rem;
- font-family: inherit;
- border-radius: 0;
- /* v1 didn't have rounded inputs usually */
-}
-
-.section-header {
- font-size: 1rem;
- /* v1 h4 size? */
- font-weight: bold;
- margin: 1rem 0 0.25rem 0;
- cursor: pointer;
- user-select: none;
- font-family: var(--font-heading);
- color: #333;
- /* Darker than list items */
- text-transform: lowercase;
- font-variant: small-caps;
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.caret {
- display: inline-block;
- font-size: 0.6rem;
- transition: transform 0.2s ease;
- color: #777;
-}
-
-.caret.expanded {
- transform: rotate(90deg);
-}
-
-.filter-list,
-.tag-list-items,
-.feed-list-items,
-.nav-list {
- list-style: none;
- padding: 0;
- margin: 0;
-}
-
-.filter-list li,
-.nav-list li {
- margin-bottom: 0.1rem;
-}
-
-.filter-list a,
-.nav-list a,
-.tag-link,
-.feed-title,
-.logout-link {
- text-decoration: none;
- color: var(--link-color, blue);
- font-size: 0.8rem;
- /* Matches v1 .75em approx */
- display: block;
- cursor: pointer;
- background: none;
- border: none;
- padding: 0;
- font-family: inherit;
- font-variant: small-caps;
- text-transform: lowercase;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.filter-list a:hover,
-.nav-list a:hover,
-.tag-link:hover,
-.feed-title:hover,
-.logout-link:hover {
- text-decoration: underline;
- color: var(--link-color, blue);
-}
-
-.filter-list a.active,
-.tag-link.active,
-.feed-title.active {
- font-weight: bold;
- color: #000;
- /* Active state black */
-}
-
-.tag-item,
-.sidebar-feed-item {
- margin-bottom: 0;
-}
-
-.feed-item-row {
- display: flex;
- align-items: center;
- gap: 0.5rem;
-}
-
-.feed-checkbox {
- cursor: pointer;
- margin: 0;
-}
-
-.feed-category {
- display: none;
-}
-
-.nav-section {
- margin-top: 2rem;
- border-top: 1px solid var(--border-color, #eee);
- padding-top: 1rem;
-}
-
-.logout-link {
- text-align: left;
- width: 100%;
- color: #777;
- display: block;
-}
-
-.nav-link,
-.logout-link {
- padding: 0.25rem 0;
-}
-
-.logout-link:hover {
- color: var(--link-color, blue);
- text-decoration: underline;
-}
-
-.theme-section {
- margin-top: 1rem;
-}
-
-.theme-selector {
- display: flex;
- gap: 0.5rem;
- margin-top: 0.5rem;
-}
-
-.theme-selector button {
- background: rgba(0, 0, 0, 0.05);
- border: none;
- cursor: pointer;
- padding: 0.4rem;
- font-size: 1rem;
- border-radius: 8px;
- line-height: 1;
- transition: all 0.2s ease;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.theme-selector button:hover {
- background: rgba(0, 0, 0, 0.1);
- transform: translateY(-2px);
-}
-
-.theme-selector button.active {
- background: var(--border-color, #999);
- color: white;
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
-}
-
-.theme-dark .theme-selector button {
- background: rgba(255, 255, 255, 0.1);
-}
-
-.theme-dark .theme-selector button:hover {
- background: rgba(255, 255, 255, 0.2);
-}
-
-/* Scrollbar styling for webkit */
-.dashboard-sidebar::-webkit-scrollbar {
- width: 4px;
-}
-
-.dashboard-sidebar::-webkit-scrollbar-thumb {
- background-color: var(--border-color, #999);
-} \ No newline at end of file
diff --git a/frontend/src/components/FeedList.test.tsx b/frontend/src/components/FeedList.test.tsx
deleted file mode 100644
index d4e72cc..0000000
--- a/frontend/src/components/FeedList.test.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-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 FeedList from './FeedList';
-
-import { BrowserRouter } from 'react-router-dom';
-
-describe('FeedList Component', () => {
- beforeEach(() => {
- vi.resetAllMocks();
- global.fetch = vi.fn();
- });
-
- it('renders loading state initially', () => {
- vi.mocked(global.fetch).mockImplementation(() => new Promise(() => { }));
- render(
- <BrowserRouter>
- <FeedList
- theme="light"
- setTheme={() => { }}
- setSidebarVisible={() => { }}
- isMobile={false}
- />
- </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',
- },
- ];
-
- vi.mocked(global.fetch).mockImplementation((url) => {
- const urlStr = url.toString();
- if (urlStr.includes('/api/feed/')) {
- return Promise.resolve({
- ok: true,
- json: async () => mockFeeds,
- } as Response);
- }
- if (urlStr.includes('/api/tag')) {
- return Promise.resolve({
- ok: true,
- json: async () => [{ title: 'Tech' }],
- } as Response);
- }
- return Promise.reject(new Error(`Unknown URL: ${url}`));
- });
-
- render(
- <BrowserRouter>
- <FeedList
- theme="light"
- setTheme={() => { }}
- setSidebarVisible={() => { }}
- isMobile={false}
- />
- </BrowserRouter>
- );
-
- await waitFor(() => {
- expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument();
- });
-
- // Expand feeds
- fireEvent.click(screen.getByText(/feeds/i, { selector: 'h4' }));
-
- 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 () => {
- vi.mocked(global.fetch).mockImplementation(() => Promise.reject(new Error('API Error')));
-
- render(
- <BrowserRouter>
- <FeedList
- theme="light"
- setTheme={() => { }}
- setSidebarVisible={() => { }}
- isMobile={false}
- />
- </BrowserRouter>
- );
-
- await waitFor(() => {
- expect(screen.getByText(/error: api error/i)).toBeInTheDocument();
- });
- });
-
- it('handles empty feed list', async () => {
- vi.mocked(global.fetch).mockImplementation((url) => {
- const urlStr = url.toString();
- if (urlStr.includes('/api/feed/')) {
- return Promise.resolve({
- ok: true,
- json: async () => [],
- } as Response);
- }
- if (urlStr.includes('/api/tag')) {
- return Promise.resolve({
- ok: true,
- json: async () => [],
- } as Response);
- }
- return Promise.reject(new Error(`Unknown URL: ${url}`));
- });
-
- render(
- <BrowserRouter>
- <FeedList
- theme="light"
- setTheme={() => { }}
- setSidebarVisible={() => { }}
- isMobile={false}
- />
- </BrowserRouter>
- );
-
- await waitFor(() => {
- expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument();
- });
-
- // Expand feeds
- fireEvent.click(screen.getByText(/feeds/i, { selector: 'h4' }));
-
- await waitFor(() => {
- expect(screen.getByText(/no feeds found/i)).toBeInTheDocument();
- });
- });
-
- it('handles search submission', async () => {
- vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => [] } as Response);
- render(
- <BrowserRouter>
- <FeedList theme="light" setTheme={() => { }} setSidebarVisible={() => { }} isMobile={false} />
- </BrowserRouter>
- );
-
- // Wait for load
- await waitFor(() => {
- expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument();
- });
-
- const searchInput = screen.getByPlaceholderText(/search\.\.\./i);
- fireEvent.change(searchInput, { target: { value: 'test search' } });
- fireEvent.submit(searchInput.closest('form')!);
-
- // Should navigate to include search query
- // Since we're using BrowserRouter in test, we can only check if it doesn't crash
- // but we can't easily check 'navigate' unless we mock it.
- });
-
- it('handles logout', async () => {
- vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => [] } as Response);
-
- // Mock window.location
- const originalLocation = window.location;
- const locationMock = new URL('http://localhost/v2/');
-
- delete (window as { location?: Location }).location;
- (window as { location?: unknown }).location = {
- ...originalLocation,
- assign: vi.fn(),
- replace: vi.fn(),
- get href() { return locationMock.href; },
- set href(val: string) { locationMock.href = new URL(val, locationMock.origin).href; }
- };
-
- render(
- <BrowserRouter>
- <FeedList theme="light" setTheme={() => { }} setSidebarVisible={() => { }} isMobile={false} />
- </BrowserRouter>
- );
-
- // Wait for load
- await waitFor(() => {
- expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument();
- });
-
- const logoutBtn = screen.getByText(/logout/i);
- fireEvent.click(logoutBtn);
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith('/api/logout', expect.any(Object));
- expect(window.location.href).toContain('/v2/#/login');
- });
- // @ts-expect-error - restoring window.location
- window.location = originalLocation;
- });
-
- it('closes sidebar on mobile link click', async () => {
- vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => [] } as Response);
- const setSidebarVisible = vi.fn();
- render(
- <BrowserRouter>
- <FeedList theme="light" setTheme={() => { }} setSidebarVisible={setSidebarVisible} isMobile={true} />
- </BrowserRouter>
- );
-
- // Wait for load
- await waitFor(() => {
- expect(screen.queryByText(/loading feeds/i)).not.toBeInTheDocument();
- });
-
- const unreadLink = screen.getByText(/unread/i);
- fireEvent.click(unreadLink);
-
- expect(setSidebarVisible).toHaveBeenCalledWith(false);
- });
-});
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
deleted file mode 100644
index ce83333..0000000
--- a/frontend/src/components/FeedList.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Link, useNavigate, useSearchParams, useLocation, useMatch } from 'react-router-dom';
-import type { Feed, Category } from '../types';
-import './FeedList.css';
-import './FeedListVariants.css';
-import { apiFetch } from '../utils';
-
-export default function FeedList({
- theme,
- setTheme,
- setSidebarVisible,
- isMobile,
-}: {
- theme: string;
- setTheme: (t: string) => void;
- setSidebarVisible: (visible: boolean) => void;
- isMobile: boolean;
-}) {
- 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 [tagsExpanded, setTagsExpanded] = useState(true);
- const [searchQuery, setSearchQuery] = useState('');
- const navigate = useNavigate();
- const [searchParams] = useSearchParams();
- const location = useLocation();
- const feedMatch = useMatch('/feed/:feedId');
- const tagMatch = useMatch('/tag/:tagName');
- const isRoot = useMatch('/');
- const isStreamPage = !!(isRoot || feedMatch || tagMatch);
-
- const feedId = feedMatch?.params.feedId;
- const tagName = tagMatch?.params.tagName;
-
- const sidebarVariant = searchParams.get('sidebar') || localStorage.getItem('neko-sidebar-variant') || 'glass';
-
- useEffect(() => {
- const variant = searchParams.get('sidebar');
- if (variant) {
- localStorage.setItem('neko-sidebar-variant', variant);
- }
- }, [searchParams]);
-
- const currentFilter = searchParams.get('filter') || (isStreamPage ? 'unread' : '');
-
- const getFilterLink = (filter: string) => {
- const baseStreamPath = isStreamPage ? location.pathname : '/';
- const params = new URLSearchParams(searchParams);
- params.set('filter', filter);
- return `${baseStreamPath}?${params.toString()}`;
- };
-
- const getNavPath = (path: string) => {
- const params = new URLSearchParams(searchParams);
- if (!params.has('filter') && currentFilter) {
- params.set('filter', currentFilter);
- }
- const qs = params.toString();
- return `${path}${qs ? '?' + qs : ''}`;
- };
-
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- if (searchQuery.trim()) {
- const params = new URLSearchParams(searchParams);
- params.set('q', searchQuery.trim());
- if (currentFilter) params.set('filter', currentFilter);
- navigate(`/?${params.toString()}`);
- }
- };
-
- const toggleFeeds = () => {
- setFeedsExpanded(!feedsExpanded);
- };
-
- const toggleTags = () => {
- setTagsExpanded(!tagsExpanded);
- };
-
- const handleLinkClick = () => {
- if (isMobile) {
- setSidebarVisible(false);
- }
- };
-
- useEffect(() => {
- Promise.all([
- apiFetch('/api/feed/').then((res) => {
- if (!res.ok) throw new Error('Failed to fetch feeds');
- return res.json() as Promise<Feed[]>;
- }),
- apiFetch('/api/tag').then((res) => {
- if (!res.ok) throw new Error('Failed to fetch tags');
- return res.json() as Promise<Category[]>;
- }),
- ])
- .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>;
-
- const handleLogout = () => {
- apiFetch('/api/logout', { method: 'POST' }).then(() => (window.location.href = '/v2/#/login'));
- };
-
- return (
- <div className={`feed-list variant-${sidebarVariant}`}>
- <h1 className="logo" onClick={() => setSidebarVisible(false)}>
- 🐱
- </h1>
-
- <div className="search-section">
- <form onSubmit={handleSearch} className="search-form">
- <input
- type="search"
- placeholder="search..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- className="search-input"
- />
- </form>
- </div>
-
- <div className="filter-section">
- <ul className="filter-list">
- <li className="unread_filter">
- <Link
- to={getFilterLink('unread')}
- className={currentFilter === 'unread' ? 'active' : ''}
- onClick={handleLinkClick}
- >
- unread
- </Link>
- </li>
- <li className="all_filter">
- <Link
- to={getFilterLink('all')}
- className={currentFilter === 'all' ? 'active' : ''}
- onClick={handleLinkClick}
- >
- all
- </Link>
- </li>
- <li className="starred_filter">
- <Link
- to={getFilterLink('starred')}
- className={currentFilter === 'starred' ? 'active' : ''}
- onClick={handleLinkClick}
- >
- starred
- </Link>
- </li>
- </ul>
- </div>
-
- <div className="tag-section">
- <h4 onClick={toggleTags} className="section-header">
- <span className={`caret ${tagsExpanded ? 'expanded' : ''}`}>▶</span> Tags
- </h4>
- {tagsExpanded && (
- <ul className="tag-list-items">
- {tags.map((tag) => (
- <li key={tag.title} className="tag-item">
- <Link
- to={getNavPath(`/tag/${encodeURIComponent(tag.title)}`)}
- className={`tag-link ${tagName === tag.title ? 'active' : ''}`}
- onClick={handleLinkClick}
- >
- {tag.title}
- </Link>
- </li>
- ))}
- </ul>
- )}
- </div>
-
- <div className="feed-section">
- <h4 onClick={toggleFeeds} className="section-header">
- <span className={`caret ${feedsExpanded ? 'expanded' : ''}`}>▶</span> Feeds
- </h4>
- {feedsExpanded &&
- (feeds.length === 0 ? (
- <p>No feeds found.</p>
- ) : (
- <ul className="feed-list-items">
- {feeds.map((feed) => {
- const isSelected = feedId?.split(',').includes(String(feed._id));
-
- const toggleFeed = (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- const selectedIds = feedId ? feedId.split(',') : [];
- let newIds;
- if (isSelected) {
- newIds = selectedIds.filter(id => id !== String(feed._id));
- } else {
- newIds = [...selectedIds, String(feed._id)];
- }
-
- if (newIds.length === 0) {
- navigate(getNavPath('/'));
- } else {
- navigate(getNavPath(`/feed/${newIds.join(',')}`));
- }
- };
-
- return (
- <li key={feed._id} className="sidebar-feed-item">
- <div className="feed-item-row">
- <input
- type="checkbox"
- checked={!!isSelected}
- onChange={() => { }} // Controlled by div click for better hit area
- onClick={toggleFeed}
- className="feed-checkbox"
- />
- <Link
- to={getNavPath(`/feed/${feed._id}`)}
- className={`feed-title ${isSelected ? 'active' : ''}`}
- onClick={handleLinkClick}
- >
- {feed.title || feed.url}
- </Link>
- </div>
- </li>
- );
- })}
- </ul>
- ))}
- </div>
-
- <div className="nav-section">
- <ul className="nav-list">
- <li>
- <Link to="/settings" className="nav-link" onClick={handleLinkClick}>
- settings
- </Link>
- </li>
- <li>
- <button onClick={handleLogout} className="logout-link">
- logout
- </button>
- </li>
- </ul>
- </div>
-
- <div className="theme-section">
- <div className="theme-selector">
- <button
- onClick={() => setTheme('light')}
- className={theme === 'light' ? 'active' : ''}
- title="Light Theme"
- >
- ☀️
- </button>
- <button
- onClick={() => setTheme('dark')}
- className={theme === 'dark' ? 'active' : ''}
- title="Dark Theme"
- >
- 🌙
- </button>
- </div>
- </div>
- </div>
- );
-}
diff --git a/frontend/src/components/FeedListVariants.css b/frontend/src/components/FeedListVariants.css
deleted file mode 100644
index e97ea95..0000000
--- a/frontend/src/components/FeedListVariants.css
+++ /dev/null
@@ -1,342 +0,0 @@
-/* Glass Variant */
-.feed-list.variant-glass {
- background: rgba(255, 255, 255, 0.05);
- /* Very subtle tint */
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-right: 1px solid rgba(255, 255, 255, 0.1);
- padding: 1.5rem;
- font-family: system-ui, -apple-system, sans-serif;
- /* Modern sans */
- color: var(--text-color);
-}
-
-.feed-list.variant-glass .logo {
- font-size: 1.5rem;
- background: transparent !important;
- /* Override sticky bg */
- margin-bottom: 2rem;
- opacity: 0.8;
-}
-
-.feed-list.variant-glass .section-header {
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--text-color);
- opacity: 0.5;
- margin-top: 2rem;
- font-weight: 600;
-}
-
-.feed-list.variant-glass a,
-.feed-list.variant-glass .feed-title,
-.feed-list.variant-glass .tag-link {
- padding: 0.4rem 0.8rem;
- margin: 0.2rem 0;
- border-radius: 8px;
- transition: all 0.2s ease;
- font-weight: 500;
- text-decoration: none !important;
- /* No underlines in glass */
- color: var(--text-color);
- opacity: 0.8;
- border: none;
- /* No default borders */
-}
-
-.feed-list.variant-glass a:hover,
-.feed-list.variant-glass .feed-title:hover,
-.feed-list.variant-glass .tag-link:hover {
- background: rgba(255, 255, 255, 0.1);
- opacity: 1;
- transform: translateX(4px);
- color: var(--text-color);
-}
-
-.feed-list.variant-glass a.active,
-.feed-list.variant-glass .feed-title.active,
-.feed-list.variant-glass .tag-link.active {
- background: rgba(255, 255, 255, 0.25);
- color: var(--text-color);
- font-weight: 700;
- opacity: 1;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
-}
-
-.feed-list.variant-glass .search-input {
- border-radius: 20px;
- background: rgba(0, 0, 0, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- color: var(--text-color);
- padding: 0.5rem 1rem;
-}
-
-.feed-list.variant-glass .nav-section {
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- margin-top: 2rem;
- padding-top: 1.5rem;
-}
-
-.feed-list.variant-glass .nav-link,
-.feed-list.variant-glass .logout-link {
- opacity: 0.6;
- padding: 0.5rem 0.8rem;
- border-radius: 8px;
-}
-
-.feed-list.variant-glass .nav-link:hover,
-.feed-list.variant-glass .logout-link:hover {
- background: rgba(255, 255, 255, 0.05);
- opacity: 1;
- text-decoration: none;
-}
-
-.feed-list.variant-glass .theme-selector button {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 12px;
-}
-
-.feed-list.variant-glass .theme-selector button.active {
- background: rgba(255, 255, 255, 0.2);
- border-color: rgba(255, 255, 255, 0.3);
-}
-
-
-/* Nano Banana Variant (Playful/Pop) */
-.feed-list.variant-banana {
- background: #fdfdfd;
- padding: 1rem;
- font-family: 'Poppins', 'Verdana', sans-serif;
- border-right: none;
- box-shadow: 4px 0 24px rgba(0, 0, 0, 0.04);
-}
-
-.theme-dark .feed-list.variant-banana {
- background: #111;
-}
-
-.feed-list.variant-banana .logo {
- font-size: 2.5rem;
- text-shadow: 2px 2px 0px #FFD700;
- /* Banana yellow shadow */
- background: transparent;
- transform: rotate(-3deg);
- display: inline-block;
- margin-bottom: 2rem;
-}
-
-.feed-list.variant-banana .section-header {
- background: #FFD700;
- color: #000;
- display: inline-block;
- padding: 0.2rem 0.5rem;
- transform: skew(-10deg);
- font-size: 0.8rem;
- font-weight: 800;
- margin-bottom: 1rem;
- border-radius: 4px;
-}
-
-.feed-list.variant-banana .search-input {
- border: 2px solid #000;
- border-radius: 8px;
- box-shadow: 2px 2px 0px #000;
- transition: all 0.2s;
-}
-
-.feed-list.variant-banana .search-input:focus {
- transform: translate(1px, 1px);
- box-shadow: 1px 1px 0px #000;
- outline: none;
-}
-
-.theme-dark .feed-list.variant-banana .search-input {
- border-color: #fff;
- box-shadow: 2px 2px 0px #fff;
- background: #222;
- color: #fff;
-}
-
-.feed-list.variant-banana a,
-.feed-list.variant-banana .feed-title,
-.feed-list.variant-banana .tag-link {
- border: 1px solid transparent;
- padding: 0.5rem;
- border-radius: 8px;
- transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
- font-weight: 600;
- text-decoration: none !important;
- color: var(--text-color);
-}
-
-.feed-list.variant-banana a:hover,
-.feed-list.variant-banana .feed-title:hover,
-.feed-list.variant-banana .tag-link:hover {
- transform: scale(1.05) rotate(1deg);
- background: #fff9c4;
- /* Light yellow */
- color: #000;
- box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
-}
-
-.theme-dark .feed-list.variant-banana a:hover,
-.theme-dark .feed-list.variant-banana .feed-title:hover,
-.theme-dark .feed-list.variant-banana .tag-link:hover {
- background: #333;
- color: #FFD700;
-}
-
-
-.feed-list.variant-banana a.active,
-.feed-list.variant-banana .feed-title.active,
-.feed-list.variant-banana .tag-link.active {
- background: #FFD700;
- color: #000 !important;
- transform: scale(1.02);
- box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.1);
- border: 2px solid #000;
-}
-
-.feed-list.variant-banana .nav-section {
- border-top: 2px dashed #FFD700;
- margin-top: 2rem;
- padding-top: 1rem;
-}
-
-.feed-list.variant-banana .theme-selector button {
- border: 2px solid #000;
- box-shadow: 2px 2px 0px #000;
- border-radius: 4px;
-}
-
-.feed-list.variant-banana .theme-selector button.active {
- background: #FFD700;
- transform: translate(1px, 1px);
- box-shadow: 1px 1px 0px #000;
-}
-
-
-/* Type Variant (Swiss/Bold) */
-.feed-list.variant-type {
- background: var(--bg-color);
- padding: 2rem 1rem;
- font-family: 'Helvetica Neue', 'Arial', sans-serif;
- border-right: 4px solid var(--text-color);
-}
-
-.feed-list.variant-type .logo {
- font-size: 3rem;
- letter-spacing: -2px;
- font-weight: 900;
- background: transparent;
- line-height: 0.8;
- margin-bottom: 3rem;
- color: var(--text-color);
-}
-
-.feed-list.variant-type .section-header {
- font-size: 1.2rem;
- font-weight: 900;
- border-bottom: 2px solid var(--text-color);
- padding-bottom: 0.5rem;
- margin-top: 3rem;
- margin-bottom: 1rem;
- letter-spacing: -0.5px;
- color: var(--text-color);
-}
-
-.feed-list.variant-type a,
-.feed-list.variant-type .feed-title,
-.feed-list.variant-type .tag-link {
- font-size: 1rem;
- font-weight: 700;
- text-decoration: none !important;
- border-left: 0px solid var(--text-color);
- padding-left: 0;
- transition: padding-left 0.2s, border-left-width 0.2s;
- opacity: 0.6;
- color: var(--text-color);
- padding: 0.5rem 0;
- display: block;
-}
-
-.feed-list.variant-type a:hover,
-.feed-list.variant-type .feed-title:hover,
-.feed-list.variant-type .tag-link:hover {
- padding-left: 1rem;
- border-left: 4px solid var(--text-color);
- opacity: 1;
- color: var(--text-color);
-}
-
-.feed-list.variant-type a.active,
-.feed-list.variant-type .feed-title.active,
-.feed-list.variant-type .tag-link.active {
- padding-left: 1rem;
- border-left: 8px solid var(--text-color);
- opacity: 1;
- color: var(--text-color);
-}
-
-.feed-list.variant-type .search-input {
- border: none;
- border-bottom: 2px solid var(--text-color);
- background: transparent;
- border-radius: 0;
- padding: 1rem 0;
- font-weight: bold;
- font-size: 1.2rem;
-}
-
-.feed-list.variant-type .search-input:focus {
- outline: none;
- border-bottom-width: 4px;
-}
-.feed-list.variant-type .nav-section {
- border-top: 4px solid var(--text-color);
- margin-top: 4rem;
- padding-top: 1rem;
-}
-
-.feed-list.variant-type .nav-link,
-.feed-list.variant-type .logout-link {
- font-size: 1.2rem;
- font-weight: 900;
-}
-
-.feed-list.variant-type .theme-selector button {
- border-radius: 0;
- border: 2px solid var(--text-color);
- background: transparent;
-}
-
-.feed-list.variant-type .theme-selector button.active {
- background: var(--text-color);
- color: var(--bg-color);
-}
-
-.feed-list.variant-type .nav-section {
- border-top: 4px solid var(--text-color);
- margin-top: 4rem;
- padding-top: 1rem;
-}
-
-.feed-list.variant-type .nav-link,
-.feed-list.variant-type .logout-link {
- font-size: 1.2rem;
- font-weight: 900;
-}
-
-.feed-list.variant-type .theme-selector button {
- border-radius: 0;
- border: 2px solid var(--text-color);
- background: transparent;
-}
-
-.feed-list.variant-type .theme-selector button.active {
- background: var(--text-color);
- color: var(--bg-color);
-}
diff --git a/frontend/src/components/Login.css b/frontend/src/components/Login.css
deleted file mode 100644
index 6f40731..0000000
--- a/frontend/src/components/Login.css
+++ /dev/null
@@ -1,63 +0,0 @@
-.login-container {
- display: flex;
- justify-content: center;
- align-items: center;
- height: 100vh;
- background-color: #f5f5f5;
-}
-
-.login-form {
- background: white;
- padding: 2rem;
- border-radius: 8px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
- width: 100%;
- max-width: 400px;
-}
-
-.login-form h1 {
- margin-bottom: 2rem;
- text-align: center;
- color: #333;
-}
-
-.form-group {
- margin-bottom: 1.5rem;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 0.5rem;
- font-weight: bold;
- color: #555;
-}
-
-.form-group input {
- width: 100%;
- padding: 0.75rem;
- border: 1px solid #ddd;
- border-radius: 4px;
- font-size: 1rem;
-}
-
-.error-message {
- color: #dc3545;
- margin-bottom: 1rem;
- text-align: center;
-}
-
-button[type='submit'] {
- width: 100%;
- padding: 0.75rem;
- background-color: #007bff;
- color: white;
- border: none;
- border-radius: 4px;
- font-size: 1rem;
- cursor: pointer;
- transition: background-color 0.2s;
-}
-
-button[type='submit']:hover {
- background-color: #0056b3;
-}
diff --git a/frontend/src/components/Login.test.tsx b/frontend/src/components/Login.test.tsx
deleted file mode 100644
index 47f37e3..0000000
--- a/frontend/src/components/Login.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react';
-import '@testing-library/jest-dom';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
-import { BrowserRouter } from 'react-router-dom';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import Login from './Login';
-
-// Mock fetch
-global.fetch = vi.fn();
-
-const renderLogin = () => {
- render(
- <BrowserRouter>
- <Login />
- </BrowserRouter>
- );
-};
-
-describe('Login Component', () => {
- beforeEach(() => {
- vi.resetAllMocks();
- });
-
- it('renders login form', () => {
- renderLogin();
- expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
- expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
- expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
- });
-
- it('handles successful login', async () => {
- vi.mocked(global.fetch).mockResolvedValueOnce({
- ok: true,
- } as Response);
-
- renderLogin();
-
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'testuser' } });
- 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',
- body: expect.any(URLSearchParams),
- })
- );
- });
-
- // Check if params contained username and password
- const callArgs = vi.mocked(global.fetch).mock.calls[0][1];
- const body = callArgs?.body as URLSearchParams;
- expect(body.get('username')).toBe('testuser');
- expect(body.get('password')).toBe('secret');
-
- // 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 () => {
- vi.mocked(global.fetch).mockResolvedValueOnce({
- ok: false,
- json: async () => ({ message: 'Bad credentials' }),
- } as Response);
-
- renderLogin();
-
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'testuser' } });
- 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();
- });
- });
-
- it('handles network error', async () => {
- vi.mocked(global.fetch).mockRejectedValueOnce(new Error('Network error'));
-
- renderLogin();
-
- fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'testuser' } });
- 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();
- });
- });
-});
diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx
deleted file mode 100644
index 87694cb..0000000
--- a/frontend/src/components/Login.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { useState, type FormEvent } from 'react';
-import { useNavigate } from 'react-router-dom';
-import './Login.css';
-
-import { apiFetch } from '../utils';
-
-export default function Login() {
- const [username, setUsername] = useState('neko');
- const [password, setPassword] = useState('');
- const [error, setError] = useState('');
- const navigate = useNavigate();
-
- 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('username', username);
- params.append('password', password);
-
- const res = await apiFetch('/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');
- }
- };
-
- return (
- <div className="login-container">
- <form onSubmit={handleSubmit} className="login-form">
- <h1>neko rss mode</h1>
- <div className="form-group">
- <label htmlFor="username">username</label>
- <input
- id="username"
- type="text"
- value={username}
- onChange={(e) => setUsername(e.target.value)}
- />
- </div>
- <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
deleted file mode 100644
index ae43be4..0000000
--- a/frontend/src/components/Settings.css
+++ /dev/null
@@ -1,240 +0,0 @@
-.settings-page.variant-glass {
- padding: 2.5rem;
- max-width: 800px;
- margin: 0 auto;
- background: rgba(255, 255, 255, 0.05);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-radius: 24px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- font-family: system-ui, -apple-system, sans-serif;
- color: var(--text-color);
- margin-top: 2rem;
- margin-bottom: 2rem;
-}
-
-.settings-page.variant-glass h2,
-.settings-page.variant-glass h3 {
- font-weight: 700;
- letter-spacing: -0.02em;
- color: var(--text-color);
- opacity: 0.9;
-}
-
-.add-feed-section,
-.appearance-section,
-.import-section,
-.export-section,
-.feed-list-section {
- background: rgba(255, 255, 255, 0.03);
- padding: 1.5rem;
- border-radius: 16px;
- margin-bottom: 2rem;
- border: 1px solid rgba(255, 255, 255, 0.05);
- transition: all 0.3s ease;
-}
-
-.add-feed-section:hover,
-.appearance-section:hover,
-.import-section:hover,
-.export-section:hover,
-.feed-list-section:hover {
- background: rgba(255, 255, 255, 0.06);
- border-color: rgba(255, 255, 255, 0.1);
-}
-
-.font-selector {
- display: flex;
- align-items: center;
- gap: 1rem;
-}
-
-.font-select {
- padding: 0.6rem 1rem;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: rgba(0, 0, 0, 0.1);
- color: var(--text-color);
- border-radius: 20px;
- font-size: 1rem;
- min-width: 200px;
- cursor: pointer;
- outline: none;
- transition: border-color 0.2s;
-}
-
-.font-select:focus {
- border-color: rgba(255, 255, 255, 0.3);
-}
-
-.add-feed-form {
- display: flex;
- gap: 1rem;
-}
-
-.feed-input {
- flex: 1;
- padding: 0.6rem 1.2rem;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: rgba(0, 0, 0, 0.1);
- color: var(--text-color);
- border-radius: 20px;
- font-size: 1rem;
- outline: none;
- transition: border-color 0.2s;
-}
-
-.feed-input:focus {
- border-color: rgba(255, 255, 255, 0.3);
-}
-
-.error-message {
- color: #ff5252;
- margin-top: 1rem;
- font-weight: 600;
-}
-
-.settings-feed-list {
- list-style: none;
- padding: 0;
- border: 1px solid rgba(255, 255, 255, 0.05);
- border-radius: 12px;
- overflow: hidden;
-}
-
-.settings-feed-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1.2rem;
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
- transition: background 0.2s;
-}
-
-.settings-feed-item:hover {
- background: rgba(255, 255, 255, 0.02);
-}
-
-.settings-feed-item:last-child {
- border-bottom: none;
-}
-
-.feed-info {
- display: flex;
- flex-direction: column;
- gap: 0.2rem;
-}
-
-.feed-title {
- font-weight: 600;
- font-size: 1.05rem;
- opacity: 0.9;
-}
-
-.feed-url {
- color: var(--text-color);
- opacity: 0.5;
- font-size: 0.85rem;
-}
-
-.delete-btn {
- background: rgba(255, 82, 82, 0.15);
- color: #ff8a80;
- border: 1px solid rgba(255, 82, 82, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.2s;
-}
-
-.delete-btn:hover:not(:disabled) {
- background: rgba(255, 82, 82, 0.3);
- color: #fff;
- border-color: rgba(255, 82, 82, 0.4);
- transform: scale(1.05);
-}
-
-.import-export-section {
- display: flex;
- gap: 2rem;
-}
-
-@media (max-width: 768px) {
- .settings-page.variant-glass {
- padding: 1.5rem;
- margin-top: 1rem;
- }
-
- .add-feed-form {
- flex-direction: column;
- }
-
- .import-export-section {
- flex-direction: column;
- gap: 1rem;
- }
-
- .settings-feed-item {
- flex-direction: column;
- align-items: flex-start;
- gap: 1rem;
- }
-}
-
-.import-form {
- display: flex;
- flex-direction: column;
- gap: 1.2rem;
-}
-
-.file-input {
- font-size: 0.9rem;
- max-width: 100%;
- color: var(--text-color);
- opacity: 0.8;
-}
-
-.export-buttons {
- display: flex;
- gap: 0.8rem;
- flex-wrap: wrap;
-}
-
-.export-btn {
- display: inline-block;
- padding: 0.6rem 1.2rem;
- background: rgba(255, 255, 255, 0.05);
- color: var(--text-color);
- text-decoration: none;
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 12px;
- font-weight: 600;
- transition: all 0.2s;
-}
-
-.export-btn:hover {
- background: rgba(255, 255, 255, 0.1);
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-}
-
-button:not(.delete-btn) {
- cursor: pointer;
- padding: 0.6rem 1.2rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: rgba(255, 255, 255, 0.1);
- color: var(--text-color);
- font-weight: 600;
- transition: all 0.2s;
-}
-
-button:not(.delete-btn):hover:not(:disabled) {
- background: rgba(255, 255, 255, 0.2);
- transform: scale(1.02);
-}
-
-button:disabled {
- opacity: 0.4;
- 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
deleted file mode 100644
index 5b0518c..0000000
--- a/frontend/src/components/Settings.test.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-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' },
- ];
-
- vi.mocked(global.fetch).mockResolvedValueOnce({
- ok: true,
- json: async () => mockFeeds,
- } as Response);
-
- 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 () => {
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => [] } as Response) // Initial load
- .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response) // Add feed
- .mockResolvedValueOnce({
- ok: true,
- json: async () => [{ _id: 3, title: 'New Feed', url: 'http://new.com/rss' }],
- } as Response); // 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' },
- ];
-
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => mockFeeds } as Response) // Initial load
- .mockResolvedValueOnce({ ok: true } as Response); // 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();
- });
- });
-
- it('imports an OPML file', async () => {
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => [] } as Response) // Initial load
- .mockResolvedValueOnce({ ok: true, json: async () => ({ status: 'ok' }) } as Response) // Import
- .mockResolvedValueOnce({
- ok: true,
- json: async () => [{ _id: 1, title: 'Imported Feed', url: 'http://imported.com/rss' }],
- } as Response); // Refresh load
-
- render(<Settings />);
-
- const file = new File(['<opml>...</opml>'], 'feeds.opml', { type: 'text/xml' });
- const fileInput = screen.getByLabelText(/import feeds/i, { selector: 'input[type="file"]' });
- const importButton = screen.getByText('Import');
-
- fireEvent.change(fileInput, { target: { files: [file] } });
- await waitFor(() => {
- expect(importButton).not.toBeDisabled();
- });
-
- fireEvent.click(importButton);
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/import',
- expect.objectContaining({
- method: 'POST',
- body: expect.any(FormData),
- })
- );
- });
-
- // Check if refresh happens
- await waitFor(() => {
- expect(screen.getByText('Imported Feed')).toBeInTheDocument();
- });
- });
-
- it('triggers a crawl', async () => {
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => [] } as Response) // Initial load
- .mockResolvedValueOnce({ ok: true, json: async () => ({ message: 'crawl started' }) } as Response); // Crawl
-
- // Mock alert
- const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => { });
-
- render(<Settings />);
-
- // Wait for load
- await waitFor(() => {
- expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
- });
-
- const crawlBtn = screen.getByText(/crawl all feeds now/i);
- fireEvent.click(crawlBtn);
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith(
- '/api/crawl',
- expect.objectContaining({ method: 'POST' })
- );
- expect(alertMock).toHaveBeenCalledWith('Crawl started!');
- });
- alertMock.mockRestore();
- });
-
- it('handles API errors', async () => {
- vi.mocked(global.fetch)
- .mockResolvedValueOnce({ ok: true, json: async () => [] } as Response) // Initial load load
- .mockResolvedValueOnce({ ok: false, json: async () => ({}) } as Response); // Add feed error
-
- render(<Settings />);
-
- // Wait for load
- 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://fail.com/rss' } });
- fireEvent.click(button);
-
- await waitFor(() => {
- expect(screen.getByText(/failed to add feed/i)).toBeInTheDocument();
- });
- });
-
- it('handles font theme change', async () => {
- const setFontTheme = vi.fn();
- vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => [] } as Response);
-
- render(<Settings fontTheme="default" setFontTheme={setFontTheme} />);
-
- // Wait for load
- await waitFor(() => {
- expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
- });
-
- const select = screen.getByLabelText(/font theme/i);
- fireEvent.change(select, { target: { value: 'serif' } });
-
- expect(setFontTheme).toHaveBeenCalledWith('serif');
- });
-});
diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx
deleted file mode 100644
index 3dab77f..0000000
--- a/frontend/src/components/Settings.tsx
+++ /dev/null
@@ -1,236 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import type { Feed } from '../types';
-import './Settings.css';
-import { apiFetch } from '../utils';
-
-interface SettingsProps {
- fontTheme?: string;
- setFontTheme?: (t: string) => void;
-}
-
-export default function Settings({ fontTheme, setFontTheme }: SettingsProps) {
- const [feeds, setFeeds] = useState<Feed[]>([]);
- /* ... existing state ... */
- const [newFeedUrl, setNewFeedUrl] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- const [importFile, setImportFile] = useState<File | null>(null);
-
- /* ... existing fetchFeeds ... */
- const fetchFeeds = React.useCallback(() => {
- setLoading(true);
- apiFetch('/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(() => {
- // eslint-disable-next-line
- fetchFeeds();
- }, [fetchFeeds]);
-
- /* ... existing handlers ... */
- const handleAddFeed = (e: React.FormEvent) => {
- e.preventDefault();
- if (!newFeedUrl) return;
-
- setLoading(true);
- apiFetch('/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();
- })
- .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);
- apiFetch(`/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);
- });
- };
-
- const handleImport = (e: React.FormEvent) => {
- e.preventDefault();
- if (!importFile) return;
-
- setLoading(true);
- const formData = new FormData();
- formData.append('file', importFile);
- formData.append('format', 'opml');
-
- apiFetch('/api/import', {
- method: 'POST',
- body: formData,
- })
- .then((res) => {
- if (!res.ok) throw new Error('Failed to import feeds');
- return res.json();
- })
- .then(() => {
- setImportFile(null);
- fetchFeeds();
- alert('Import successful!');
- })
- .catch((err) => {
- setError(err.message);
- setLoading(false);
- });
- };
-
- const handleCrawl = () => {
- setLoading(true);
- apiFetch('/api/crawl', {
- method: 'POST',
- })
- .then((res) => {
- if (!res.ok) throw new Error('Failed to start crawl');
- return res.json();
- })
- .then(() => {
- setLoading(false);
- alert('Crawl started!');
- })
- .catch((err) => {
- setError(err.message);
- setLoading(false);
- });
- };
-
- return (
- <div className="settings-page variant-glass">
- <h2>Settings</h2>
-
- {setFontTheme && (
- <div className="appearance-section">
- <h3>Appearance</h3>
- <div className="font-selector">
- <label htmlFor="font-theme-select">Font Theme:</label>
- <select
- id="font-theme-select"
- value={fontTheme || 'default'}
- onChange={(e) => setFontTheme(e.target.value)}
- className="font-select"
- >
- <option value="default">Default</option>
- <option value="serif">Serif</option>
- <option value="sans">Sans-Serif</option>
- <option value="mono">Monospace</option>
- </select>
- </div>
- </div>
- )}
-
- <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>
- </div>
-
- <div className="import-export-section">
- <div className="import-section">
- <h3>Import Feeds (OPML)</h3>
- <form onSubmit={handleImport} className="import-form">
- <input
- type="file"
- accept=".opml,.xml,.txt"
- aria-label="Import Feeds"
- onChange={(e) => setImportFile(e.target.files?.[0] || null)}
- className="file-input"
- disabled={loading}
- />
- <button type="submit" disabled={!importFile || loading}>
- Import
- </button>
- </form>
- </div>
-
- <div className="export-section">
- <h3>Export Feeds</h3>
- <div className="export-buttons">
- <a href="/api/export/opml" className="export-btn">OPML</a>
- <a href="/api/export/text" className="export-btn">Text</a>
- <a href="/api/export/json" className="export-btn">JSON</a>
- </div>
- </div>
-
- <div className="crawl-section">
- <h3>Actions</h3>
- <button onClick={handleCrawl} disabled={loading} className="crawl-btn">
- Crawl All Feeds Now
- </button>
- </div>
- </div>
-
- {error && <p className="error-message">{error}</p>}
-
- <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
deleted file mode 100644
index de0a318..0000000
--- a/frontend/src/components/TagView.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-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' }];
-
- vi.mocked(global.fetch).mockImplementation((url) => {
- const urlStr = url.toString();
- if (urlStr.includes('/api/feed/')) {
- return Promise.resolve({
- ok: true,
- json: async () => mockFeeds,
- } as Response);
- }
- if (urlStr.includes('/api/tag')) {
- return Promise.resolve({
- ok: true,
- json: async () => mockTags,
- } as Response);
- }
- return Promise.reject(new Error(`Unknown URL: ${url}`));
- });
-
- render(
- <MemoryRouter>
- <FeedList
- theme="light"
- setTheme={() => { }}
- setSidebarVisible={() => { }}
- isMobile={false}
- />
- </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?filter=unread');
- });
-
- 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' },
- ];
-
- vi.mocked(global.fetch).mockImplementation((url) => {
- const urlStr = url.toString();
- if (urlStr.includes('/api/stream')) {
- return Promise.resolve({
- ok: true,
- json: async () => mockItems,
- } as Response);
- }
- 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();
- });
-
- const params = new URLSearchParams();
- params.append('tag', 'Tech');
- params.append('read_filter', 'unread');
- expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
- });
-});