diff options
Diffstat (limited to 'frontend/src')
27 files changed, 0 insertions, 3713 deletions
diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 0723023..0000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,136 +0,0 @@ -/* Resets and Base Styles */ -* { - box-sizing: border-box; -} - -body { - margin: 0; -} - -/* Dashboard Layout */ -.dashboard { - display: flex; - flex-direction: column; - height: 100vh; - height: 100dvh; - width: 100%; - overflow: hidden; - /* Prevent body scroll */ -} - -/* Header styles removed as we moved to sidebar navigation */ - -.dashboard-content { - display: flex; - flex: 1; - overflow: hidden; - position: relative; - width: 100%; -} - -.dashboard-sidebar { - width: 11rem; - background: transparent; - border-right: 1px solid var(--border-color); - display: flex; - flex-direction: column; - overflow-y: auto; - transition: margin-left 0.4s ease; - /* No padding here, handled in FeedList */ -} - -.dashboard-sidebar.hidden { - margin-left: -11rem; -} - -.dashboard-main { - flex: 1; - min-width: 0; - padding: 2rem; - overflow-y: auto; - overflow-x: hidden; - background: var(--bg-color); - margin-left: 0; -} - -.dashboard-main>* { - max-width: 35em; - margin: 0 auto; -} - -.fixed-toggle { - position: absolute; - top: 1rem; - left: 1rem; - z-index: 1000; - background: var(--bg-color); - /* Added bg to be visible over content if needed */ - background: none; - border: none; - font-size: 2rem; - line-height: 1; - cursor: pointer; - padding: 0.2rem; - color: var(--text-color); - display: flex; - align-items: center; - justify-content: center; -} - -.fixed-toggle:hover { - transform: scale(1.1); -} - -/* Mobile Responsiveness */ -@media (max-width: 768px) { - .dashboard-sidebar { - position: fixed; - top: 0; - left: 0; - bottom: 0; - z-index: 1100; - box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2); - width: 14rem; - /* Slightly wider on mobile for better target area */ - } - - .dashboard-sidebar.hidden { - margin-left: -14rem; - } - - .dashboard-main { - padding: 1rem; - padding-top: 4rem; - /* Space for the toggle button */ - } - - .dashboard-main>* { - max-width: 100%; - } - - /* When sidebar is visible on mobile, we show a backdrop */ - .sidebar-backdrop { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.4); - z-index: 1050; - animation: fadeIn 0.3s ease; - } - - .dashboard.sidebar-visible::after { - display: none; - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -}
\ No newline at end of file diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx deleted file mode 100644 index 27b9da2..0000000 --- a/frontend/src/App.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import '@testing-library/jest-dom'; -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; -import App from './App'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -describe('App', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - }); - - it('renders login on initial load (unauthenticated)', async () => { - vi.mocked(global.fetch).mockResolvedValueOnce({ - ok: false, - } as Response); - window.history.pushState({}, 'Test page', '/v2/login'); - render(<App />); - expect(await screen.findByRole('button', { name: /login/i })).toBeInTheDocument(); - }); - - it('renders dashboard when authenticated', async () => { - vi.mocked(global.fetch).mockImplementation((url) => { - const urlStr = url.toString(); - if (urlStr.includes('/api/auth')) return Promise.resolve({ ok: true } as Response); - 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.resolve({ ok: true } as Response); // Fallback - }); - - window.history.pushState({}, 'Test page', '/v2/'); - render(<App />); - - await waitFor(() => { - expect(screen.getByText('🐱')).toBeInTheDocument(); - }); - - // Test Logout - const logoutBtn = screen.getByText(/logout/i); - expect(logoutBtn).toBeInTheDocument(); - - // Mock window.location - Object.defineProperty(window, 'location', { - configurable: true, - value: { href: '' }, - }); - - vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true } as Response); - - fireEvent.click(logoutBtn); - - await waitFor(() => { - expect(global.fetch).toHaveBeenCalledWith( - '/api/logout', - expect.objectContaining({ method: 'POST' }) - ); - expect(window.location.href).toBe('/v2/#/login'); - }); - }); -}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index cc45949..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; -import Login from './components/Login'; -import './App.css'; -import { apiFetch } from './utils'; - -// Protected Route wrapper -function RequireAuth({ children }: { children: React.ReactElement }) { - const [auth, setAuth] = useState<boolean | null>(null); - const location = useLocation(); - - useEffect(() => { - apiFetch('/api/auth') - .then((res) => { - if (res.ok) { - setAuth(true); - } else { - setAuth(false); - } - }) - .catch(() => setAuth(false)); - }, []); - - if (auth === null) { - return <div>Loading...</div>; - } - - if (!auth) { - return <Navigate to="/login" state={{ from: location }} replace />; - } - - return children; -} - -import FeedList from './components/FeedList'; -import FeedItems from './components/FeedItems'; -import Settings from './components/Settings'; - -interface DashboardProps { - theme: string; - setTheme: (t: string) => void; - fontTheme: string; - setFontTheme: (t: string) => void; -} - -function Dashboard({ theme, setTheme, fontTheme, setFontTheme }: DashboardProps) { - const [sidebarVisible, setSidebarVisible] = useState(window.innerWidth > 768); - - useEffect(() => { - const handleResize = () => { - if (window.innerWidth > 768) { - setSidebarVisible(true); - } else { - setSidebarVisible(false); - } - }; - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, []); - - return ( - <div - className={`dashboard ${sidebarVisible ? 'sidebar-visible' : 'sidebar-hidden'} theme-${theme} font-${fontTheme}`} - > - <div className="dashboard-content"> - {(!sidebarVisible || window.innerWidth <= 768) && ( - <button - className="sidebar-toggle fixed-toggle" - onClick={() => setSidebarVisible(!sidebarVisible)} - title={sidebarVisible ? "Hide Sidebar" : "Show Sidebar"} - > - 🐱 - </button> - )} - {sidebarVisible && ( - <div - className="sidebar-backdrop" - onClick={() => setSidebarVisible(false)} - /> - )} - <aside className={`dashboard-sidebar ${sidebarVisible ? '' : 'hidden'}`}> - <FeedList - theme={theme} - setTheme={setTheme} - setSidebarVisible={setSidebarVisible} - isMobile={window.innerWidth <= 768} - /> - </aside> - <main className="dashboard-main"> - <Routes> - <Route path="/feed/:feedId" element={<FeedItems />} /> - <Route path="/tag/:tagName" element={<FeedItems />} /> - <Route path="/settings" element={<Settings fontTheme={fontTheme} setFontTheme={setFontTheme} />} /> - <Route path="/" element={<FeedItems />} /> - </Routes> - </main> - </div> - </div> - ); -} - -function App() { - const [theme, setTheme] = useState(localStorage.getItem('neko-theme') || 'light'); - const [fontTheme, setFontTheme] = useState(localStorage.getItem('neko-font-theme') || 'default'); - - const handleSetTheme = (newTheme: string) => { - setTheme(newTheme); - localStorage.setItem('neko-theme', newTheme); - }; - - const handleSetFontTheme = (newFontTheme: string) => { - setFontTheme(newFontTheme); - localStorage.setItem('neko-font-theme', newFontTheme); - }; - - - - return ( - <HashRouter> - <Routes> - <Route path="/login" element={<Login />} /> - <Route - path="/*" - element={ - <RequireAuth> - <Dashboard - theme={theme} - setTheme={handleSetTheme} - fontTheme={fontTheme} - setFontTheme={handleSetFontTheme} - /> - </RequireAuth> - } - /> - </Routes> - </HashRouter> - ); -} - -export default App; diff --git a/frontend/src/Navigation.test.tsx b/frontend/src/Navigation.test.tsx deleted file mode 100644 index b0bae86..0000000 --- a/frontend/src/Navigation.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import React from 'react'; -import App from './App'; -import '@testing-library/jest-dom'; - -describe('Navigation and Filtering', () => { - beforeEach(() => { - vi.resetAllMocks(); - global.fetch = vi.fn(); - // Default mock response for auth - vi.mocked(global.fetch).mockImplementation((url) => { - const urlStr = url.toString(); - if (urlStr.includes('/api/auth')) return Promise.resolve({ ok: true, json: async () => ({ status: 'ok' }) } as Response); - if (urlStr.includes('/api/feed/')) return Promise.resolve({ - ok: true, - json: async () => [ - { _id: 1, title: 'Feed 1', url: 'http://f1.com' }, - { _id: 2, title: 'Feed 2', url: 'http://f2.com' } - ] - } as Response); - if (urlStr.includes('/api/tag')) return Promise.resolve({ ok: true, json: async () => [] } as Response); - if (urlStr.includes('/api/stream')) return Promise.resolve({ ok: true, json: async () => [] } as Response); - return Promise.resolve({ ok: true, json: async () => ({}) } as Response); - }); - }); - - it('preserves "all" filter when clicking a feed', async () => { - Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 }); - window.history.pushState({}, '', '/#/'); - render(<App />); - - // Wait for sidebar to load and feeds section to be visible - await waitFor(() => { - expect(screen.queryByText(/Loading feeds/i)).not.toBeInTheDocument(); - }); - - // Expand feeds if not expanded - const feedsHeader = await screen.findByRole('heading', { name: /Feeds/i, level: 4 }); - fireEvent.click(feedsHeader); - - await waitFor(() => { - expect(screen.getByText('Feed 1')).toBeInTheDocument(); - }); - // Click 'all' filter - const allFilter = screen.getByText('all'); - fireEvent.click(allFilter); - - // Verify URL has filter=all - await waitFor(() => { - expect(window.location.hash).toContain('filter=all'); - }); - - // Click Feed 1 - const feed1Link = screen.getByText('Feed 1'); - fireEvent.click(feed1Link); - - // Verify URL is /feed/1?filter=all (or similar) - await waitFor(() => { - expect(window.location.hash).toContain('/feed/1'); - expect(window.location.hash).toContain('filter=all'); - }); - - // Click Feed 2 - const feed2Link = screen.getByText('Feed 2'); - fireEvent.click(feed2Link); - - // Verify URL is /feed/2?filter=all - await waitFor(() => { - expect(window.location.hash).toContain('/feed/2'); - expect(window.location.hash).toContain('filter=all'); - }); - }); - - it('highlights the correct filter link', async () => { - window.history.pushState({}, '', '/#/'); - render(<App />); - - await waitFor(() => { - const unreadLink = screen.getByText('unread'); - expect(unreadLink.className).toContain('active'); - }); - - fireEvent.click(screen.getByText('all')); - await waitFor(() => { - const allLink = screen.getByText('all'); - const unreadLink = screen.getByText('unread'); - expect(allLink.className).toContain('active'); - expect(unreadLink.className).not.toContain('active'); - }); - }); - - it('highlights "unread" as active even when on a feed page without filter param', async () => { - window.history.pushState({}, '', '/#/feed/1'); - render(<App />); - - await waitFor(() => { - const unreadLink = screen.getByText('unread'); - expect(unreadLink.className).toContain('active'); - }); - }); - - it('preserves search query when clicking a feed', async () => { - window.history.pushState({}, '', '/#/?q=linux'); - render(<App />); - - // Wait for load - await waitFor(() => { - expect(screen.queryByText(/Loading feeds/i)).not.toBeInTheDocument(); - }); - - const feedsHeader = await screen.findByRole('heading', { name: /Feeds/i, level: 4 }); - fireEvent.click(feedsHeader); - - await screen.findByText('Feed 1'); - fireEvent.click(screen.getByText('Feed 1')); - - await waitFor(() => { - expect(window.location.hash).toContain('/feed/1'); - expect(window.location.hash).toContain('q=linux'); - }); - }); -}); diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
\ No newline at end of file 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()); - }); -}); diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 22fc7d0..0000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,158 +0,0 @@ -:root { - /* Font Variables */ - --font-body: Palatino, 'Palatino Linotype', 'Palatino LT STD', 'Book Antiqua', Georgia, serif; - --font-heading: 'Helvetica Neue', Helvetica, Arial, sans-serif; - - line-height: 1.5; - font-size: 18px; - - /* Light Mode Defaults */ - --bg-color: #ffffff; - --text-color: rgba(0, 0, 0, 0.87); - --sidebar-bg: #ccc; - --link-color: #0000ee; - /* Standard blue link */ - - color-scheme: light dark; - color: var(--text-color); - background-color: var(--bg-color); -} - -html, -body, -#root { - width: 100%; - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; -} - -body { - font-family: var(--font-body); -} - -h1, -h2, -h3, -h4, -h5, -.logo, -.nav-link, -.logout-btn { - font-family: var(--font-heading); - font-weight: bold; -} - -/* Font Themes */ -.font-default { - /* Uses :root defaults */ - font-family: var(--font-body); -} - -.font-serif { - --font-body: Georgia, 'Times New Roman', Times, serif; - --font-heading: Georgia, 'Times New Roman', Times, serif; - font-family: var(--font-body); -} - -.font-sans { - --font-body: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - --font-heading: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - font-family: var(--font-body); -} - -.font-mono { - --font-body: Menlo, Monaco, Consolas, 'Courier New', monospace; - --font-heading: Menlo, Monaco, Consolas, 'Courier New', monospace; - font-family: var(--font-body); -} - - -.theme-light { - --bg-color: #ffffff; - --text-color: rgba(0, 0, 0, 0.87); - --sidebar-bg: #ccc; - --link-color: #0000ee; - --border-color: #999; - background-color: var(--bg-color); - color: var(--text-color); -} - -@media (prefers-color-scheme: dark) { - :root { - --bg-color: #24292e; - /* Legacy Dark */ - --text-color: #ffffff; - --sidebar-bg: #1b1f23; - /* Darker sidebar */ - --link-color: rgb(90, 200, 250); - /* Legacy dark link */ - } -} - -.theme-dark { - --bg-color: #000000; - --text-color: #ffffff; - --sidebar-bg: #111111; - --link-color: rgb(90, 200, 250); - --border-color: #333; - background-color: var(--bg-color); - color: var(--text-color); -} - -.theme-dark button { - background-color: #333; - color: #fff; -} - -body { - min-width: 320px; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: bold; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} - -button:hover { - border-color: #646cff; -} - -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -a { - color: var(--link-color); - text-decoration: none; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - - a:hover { - color: blue; - text-decoration: underline; - } - - button { - background-color: #f9f9f9; - } -}
\ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index df655ea..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; - -createRoot(document.getElementById('root')!).render( - <StrictMode> - <App /> - </StrictMode> -); diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts deleted file mode 100644 index 5781184..0000000 --- a/frontend/src/setupTests.ts +++ /dev/null @@ -1,40 +0,0 @@ -import '@testing-library/jest-dom'; - -// Mock IntersectionObserver -class IntersectionObserver { - readonly root: Element | null = null; - readonly rootMargin: string = ''; - readonly thresholds: ReadonlyArray<number> = []; - - constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) { - // nothing - } - - observe(_target: Element): void { - // nothing - } - - unobserve(_target: Element): void { - // nothing - } - - disconnect(): void { - // nothing - } - - takeRecords(): IntersectionObserverEntry[] { - return []; - } -} - -Object.defineProperty(window, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}); - -Object.defineProperty(globalThis, 'IntersectionObserver', { - writable: true, - configurable: true, - value: IntersectionObserver, -}); diff --git a/frontend/src/types.ts b/frontend/src/types.ts deleted file mode 100644 index 1feea1f..0000000 --- a/frontend/src/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface Feed { - _id: number; - url: string; - web_url: string; - title: string; - category: string; -} - -export interface Item { - _id: number; - feed_id: number; - title: string; - url: string; - description: string; - publish_date: string; - read: boolean; - starred: boolean; - full_content?: string; - header_image?: string; - feed_title?: string; -} -export interface Category { - title: string; -} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts deleted file mode 100644 index ebfb692..0000000 --- a/frontend/src/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -export function getCookie(name: string): string | undefined { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop()?.split(';').shift(); -} - -/** - * A wrapper around fetch that automatically includes the CSRF token - * for state-changing requests (POST, PUT, DELETE). - */ -export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { - const method = init?.method?.toUpperCase() || 'GET'; - const isStateChanging = ['POST', 'PUT', 'DELETE'].includes(method); - - const headers = new Headers(init?.headers || {}); - - if (isStateChanging) { - const token = getCookie('csrf_token'); - if (token) { - headers.set('X-CSRF-Token', token); - } - } - - // Ensure requests are treated as coming from our own origin if needed, - // but for a same-origin API, standard fetch defaults are usually fine. - - return fetch(input, { - ...init, - headers, - credentials: 'include', // Ensure cookies are sent - }); -} |
