diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-16 19:37:50 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-16 19:40:10 -0800 |
| commit | cba29e6aae637b04ff6eaf28f74bc15b6242b9ea (patch) | |
| tree | 226753a3fcd18a6d45089dafcb1ee72557140aa8 /frontend/src/components | |
| parent | cb6d0c9e330c27ff85ff065c2ea6dd1a756cbf6d (diff) | |
| download | neko-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.css | 158 | ||||
| -rw-r--r-- | frontend/src/components/FeedItem.test.tsx | 80 | ||||
| -rw-r--r-- | frontend/src/components/FeedItem.tsx | 90 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.css | 23 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.test.tsx | 250 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 313 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.css | 225 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.test.tsx | 230 | ||||
| -rw-r--r-- | frontend/src/components/FeedList.tsx | 279 | ||||
| -rw-r--r-- | frontend/src/components/FeedListVariants.css | 342 | ||||
| -rw-r--r-- | frontend/src/components/Login.css | 63 | ||||
| -rw-r--r-- | frontend/src/components/Login.test.tsx | 93 | ||||
| -rw-r--r-- | frontend/src/components/Login.tsx | 67 | ||||
| -rw-r--r-- | frontend/src/components/Settings.css | 240 | ||||
| -rw-r--r-- | frontend/src/components/Settings.test.tsx | 207 | ||||
| -rw-r--r-- | frontend/src/components/Settings.tsx | 236 | ||||
| -rw-r--r-- | frontend/src/components/TagView.test.tsx | 93 |
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()); - }); -}); |
