aboutsummaryrefslogtreecommitdiffstats
path: root/frontend
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 10:04:17 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 10:04:17 -0800
commit5669961d674b2764082c7c9585484cb090b71e45 (patch)
tree12cb5fadb849ecb725e7484e3717b9740800e929 /frontend
parent23a48e1d498680be769e931f46ddb1fd44f38d1a (diff)
downloadneko-5669961d674b2764082c7c9585484cb090b71e45.tar.gz
neko-5669961d674b2764082c7c9585484cb090b71e45.tar.bz2
neko-5669961d674b2764082c7c9585484cb090b71e45.zip
Implement frontend parity features: Unread view, shortcuts, scroll-to-read, filters
Diffstat (limited to 'frontend')
-rw-r--r--frontend/playwright-report/index.html2
-rw-r--r--frontend/src/App.tsx2
-rw-r--r--frontend/src/components/FeedItems.css9
-rw-r--r--frontend/src/components/FeedItems.test.tsx128
-rw-r--r--frontend/src/components/FeedItems.tsx148
-rw-r--r--frontend/src/components/FeedList.css24
-rw-r--r--frontend/src/components/FeedList.tsx7
-rw-r--r--frontend/src/components/TagView.test.tsx5
-rw-r--r--frontend/tests/e2e.spec.ts3
9 files changed, 314 insertions, 14 deletions
diff --git a/frontend/playwright-report/index.html b/frontend/playwright-report/index.html
index 3b60b51..ced641e 100644
--- a/frontend/playwright-report/index.html
+++ b/frontend/playwright-report/index.html
@@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
<div id='root'></div>
</body>
</html>
-<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,UEsDBBQAAAgIAKZMTVxdOVAEcQYAAH83AAAZAAAAYjFjNzg1OWQ5ODFjYzIwYzk2YmUuanNvbtVbfY+bNhj/KhabdO2UEmzzEjJVU69q12mn/tFdO2lNNznESdgBjsBc7tTdd58h3EEIJCZwOS7/hBfzw378e15tvitz16O/zZSxMoWONTLsmT2CjoM0xzanVBmk9z8Sn4oWFFE1WlFH5ZG4wWkk/sdfv6dHtRCvbFPHlGBMsaNbEFu6iZzkcZd7CWi0ZLE3A8Tz2Bp4bOEGA3Dt0rUbLMCc0lk0ACSYJXdYzMVzq5D9Sx2edclZhsx3Y1/c8JhDuMsCZfw97fROhz03EBf1geIwL/ZFO+NuoMziMHsKYl0bKCQIGE+vJGP7JvpJFtmReL/DNoK4EaCczpLuEL4Ut5WP9IqBT5TMaAjeoXeKaB/SKPYyEZVfE3ES8ks3RUMaMl9p6BXEl9AaY2sMkaoh4y8lgeDhrTJOH6CrTNqZ4M7pnIUUfGDsKhndYcRRgph3BGGjCva9e8NjgTtRpiFbRzScKDLo2N5Gh6ZZhX5B4sBZggxaBljHZWCUAwspE86Js/RpwLMLDosDLtqJVlfuaiVmaTwnXkTvGjUeVEnEYQGnN1xKIkgztztuVcnjbUgJpyADloItTaP+ZNJYkQWVEwUszaFRSehMFgmsFKhREoR1CkkcK7aP5NpdJMPjTIhueI2Gqa2Tk59ZohLEcP9YJW2hmdtCpN3VD0ScB8m5uKeASaxpcPrV1nwADPBfdoptcZr98hbDIYAq+JUlo75Ix/vQehJsIZn7kLDpkzVxeaFFSr7sFPtqfmfBOHtxf4r8swdRn+VtXuZP/lzbI7DVo/tD6GdHMO9l/vs7u4mQX8DdHGnlF1gNh7zxOi+Skb+sHDpnH8g1vUwY9yABwx8mrml4aPBKkazv0jcJnhYQpZiqa2ibqUUf04KoVoGoo2OI2j969Wr2O6D+USowKr5I2XYznicIOFFAShEWvjj7YUWiaM3C2dlLGSqWI5ID7kGSiVBrazPtwzYTqfutJdS6orMYqVekcy7jnckTDw4qWCEe6ptlhbAr8Tie61wV5TONOWfBV367oq8nShRPfQGkfDsogS1uv01Qc17XYUqxXLdKUZDZDc1hS5o/b4r2kEGn1iFUa5pL0cHnTxdysYGtl6JY7UDuIsvVUcvoAO71xRuT/DulK+AsqXOVFiZYCBLPDzb/tTQaPY6TFyIvunj1p8kk6cVk+MuPfXX1cK/b2y+K6kCM8vPbS5E0F5WrUIUpKlSdLM/pFzdypyJgahYUPzwnwpO8H1tvlzHehl3K5WFC5S40wi4UufAxGtF74j5LPnWoe50oJWrqpI8S4kVaun0CfcxeLKOKFizFUbgTRUSFbEE/yjU9f5o/N5I9spI20k9YG4SVcoikevz6D8q5iE4iOcYjrcT4bhJkhFtmDggdjsZ0FXyhoTu/Be8pnYELN+K19MOPFspvC/0EgVcj7ugnduFRJohWpUbZZMIyHsde6y1TiZ6yrb9keKJ0BBmHKo9Lzlfj4TCMItUJAtVhfnI8FMf/cLaKhNV1aaSKS8USpRusYn6f28ehJ1vGsUxYygSMjqzxqK01PlSndlhQNL7pmv3n0AP5bL+umFHBZ2kBV9Z56qneNHWRLjxVTW5V1wa70uiHR2gaSza0TW9ms9QdtyqGbiPJ6I4NS4V+vZvFUWS3VZ3nysTeEOXECoI1mZpnTbIpb86kSL3jEDrhNEaFfNQ6gtN4b2F8E5z/KciRVkjFH2eACBgS1k0q3hvtt8oO7xnfz0SwETFxLTF395VIxc+2WdqSk1i7LghmtDSaeG+kuiGYoYLisBNTUkuvvTtU2i78986AmQ14wslieEmdpRxfyos3uJtKtd52OR1LLKefxxysKXBIsFnBAe4c8CUFIYuFQDxGZhFYu3wpToETkmgpMpe6OdQ7W9esItT9nPSNWHrT9dCjaniXZDEG5eGfqFacv/uwe8aqhnG5eNaNg9YL6+4GPEIfesnPZ0ifx1+qaaR/9avxVfmT9MIHVnWttI0Wad0QWW9p2PW9lbSNYbfSfVJiqLXEa1r3apjJ7K469MJaNw162lb70g2QJ6j7Crqi8lZo2E3lVzdaVn57SbWeMuGJir767i7gOwmqvplzGkp+Z4NVQ9fKDN3/mY3kZxQVyAcs9WN921LRk+pvWzwWyX/akqDiJ/i0pWljGoYszNqJEfFYHCvJlsT0c7Cdz8dK2AkCu1LGPIwF2t3/UEsDBBQAAAgIAKZMTVzJCJpnaAEAAMICAAALAAAAcmVwb3J0Lmpzb26tUbtOxDAQ/JXIdTjldReSnoKGAiFRIIrF3rszcezIXvPQ6f6ddRJ0SIgOudnnzOz4JEYkUEAg+pMASRHMo/MD+iD68pyLQODpQY/IaduWRdFUdVHuqlyo6IG0s6Kvm2q76Tqu7bVBXnw6zdGtEr14KWV7ve1Ud11KWRWy272gWCbvIMEKrHATJpQbCtwgDLRApOhPiKtu19QIdY21bNqybptdJdO6JpNAw9FFozIwxr1nxh20zbM3je/aHrI9ogp5BlaljovEe5N3ryhplSSP3o06jtwwTq5nLkf9Emy05WKTC+lMHHlue/5pTlk3RS7AWkdzJd32zDrhsEbML91ixAeDEqokB+jIbXGHg8vuERT67Ka6EWl+ED35iLnwGKJZzQIikMcR7Zw/n/nNn5dStpLJDWvJLxwpifaSssi9geFzjsKgp2mtfvOdE+IPmxLPxah/Z+Nl753/NmlavTtxY+RL2fTl0C9QSwECPwMUAAAICACmTE1cXTlQBHEGAAB/NwAAGQAAAAAAAAAAAAAAtIEAAAAAYjFjNzg1OWQ5ODFjYzIwYzk2YmUuanNvblBLAQI/AxQAAAgIAKZMTVzJCJpnaAEAAMICAAALAAAAAAAAAAAAAAC0gagGAAByZXBvcnQuanNvblBLBQYAAAAAAgACAIAAAAA5CAAAAAA=</script> \ No newline at end of file
+<script id="playwrightReportBase64" type="application/zip">data:application/zip;base64,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</script> \ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 09148d6..2758472 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -61,7 +61,7 @@ function Dashboard() {
<Route path="/feed/:feedId" element={<FeedItems />} />
<Route path="/tag/:tagName" element={<FeedItems />} />
<Route path="/settings" element={<Settings />} />
- <Route path="/" element={<p>Select a feed to view items.</p>} />
+ <Route path="/" element={<FeedItems />} />
</Routes>
</main>
</div>
diff --git a/frontend/src/components/FeedItems.css b/frontend/src/components/FeedItems.css
index 795156d..f271b34 100644
--- a/frontend/src/components/FeedItems.css
+++ b/frontend/src/components/FeedItems.css
@@ -11,4 +11,11 @@
.item-list {
list-style: none;
padding: 0;
-} \ No newline at end of file
+}
+.selected-item-container {
+ border-left: 4px solid #007bff;
+ background-color: #f8f9fa;
+ padding-left: 0.5rem;
+ margin-left: -0.5rem; /* Compensate for padding/border to keep alignment */
+ transition: background-color 0.2s;
+}
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
index 9c271c4..00118fa 100644
--- a/frontend/src/components/FeedItems.test.tsx
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import FeedItems from './FeedItems';
@@ -9,6 +9,15 @@ 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();
+ }
+ window.IntersectionObserver = MockIntersectionObserver as any;
});
it('renders loading state', () => {
@@ -44,9 +53,122 @@ describe('FeedItems Component', () => {
await waitFor(() => {
expect(screen.getByText('Item One')).toBeInTheDocument();
- expect(screen.getByText('Item Two')).toBeInTheDocument();
+ // Title should now be "Feed Items" based on logic
+ expect(screen.getByText('Feed Items')).toBeInTheDocument();
+ });
+
+ const params = new URLSearchParams();
+ params.append('feed_id', '1');
+ params.append('read_filter', 'unread');
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
+ });
+
+ it('handles keyboard shortcuts', async () => {
+ const mockItems = [
+ { _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false },
+ { _id: 102, title: 'Item 2', url: 'u2', read: true, starred: false },
+ ];
+
+ (global.fetch as any).mockResolvedValue({
+ ok: true,
+ json: async () => mockItems,
+ });
+
+ render(
+ <MemoryRouter>
+ <FeedItems />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 1')).toBeVisible();
+ });
+
+ // Press 'j' to select first item (index 0 -> 1 because it starts at -1... wait logic says min(prev+1))
+ // init -1. j -> 0.
+ fireEvent.keyDown(window, { key: 'j' });
+
+ // Item 1 (index 0) should be selected.
+ // It's unread, so it should be marked read.
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith('/api/item/101', expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ read: true, starred: false }),
+ }));
+ });
+
+ // Press 'j' again -> index 1 (Item 2)
+ fireEvent.keyDown(window, { key: 'j' });
+
+ // Item 2 is already read, so no markRead call expected for it (mocks clear? no).
+ // let's check selection class if possible, but testing library doesn't easily check class on div wrapper unless we query it.
+
+ // Press 's' to star Item 2
+ fireEvent.keyDown(window, { key: 's' });
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith('/api/item/102', expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ read: true, starred: true }), // toggled to true
+ }));
+ });
+ });
+
+ it('marks items as read when scrolled past', async () => {
+ const mockItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }];
+ (global.fetch as any).mockResolvedValue({
+ ok: true,
+ json: async () => mockItems,
});
- expect(global.fetch).toHaveBeenCalledWith('/api/stream?feed_id=1');
+ // Capture the callback
+ let observerCallback: IntersectionObserverCallback = () => { };
+
+ // Override the mock to capture callback
+ class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {
+ observerCallback = callback;
+ }
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ }
+ window.IntersectionObserver = MockIntersectionObserver as any;
+
+ render(
+ <MemoryRouter>
+ <FeedItems />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 1')).toBeVisible();
+ });
+
+ // Simulate item leaving viewport at the top
+ // Element index is 0
+ const entry = {
+ isIntersecting: false,
+ boundingClientRect: { top: -50 } as DOMRectReadOnly,
+ target: { getAttribute: () => '0' } as unknown as Element,
+ intersectionRatio: 0,
+ time: 0,
+ rootBounds: null,
+ intersectionRect: {} as DOMRectReadOnly,
+ } as IntersectionObserverEntry;
+
+ // Use vi.waitUntil to wait for callback to be assigned if needed,
+ // though strictly synchronous render + effect should do it.
+ // Direct call:
+ act(() => {
+ observerCallback([entry], {} as IntersectionObserver);
+ });
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith('/api/item/101', expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ read: true, starred: false }),
+ }));
+ });
});
});
diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx
index 01a24fc..9e732a0 100644
--- a/frontend/src/components/FeedItems.tsx
+++ b/frontend/src/components/FeedItems.tsx
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
-import { useParams } from 'react-router-dom';
+import { useParams, useSearchParams } from 'react-router-dom';
import type { Item } from '../types';
import FeedItem from './FeedItem';
import './FeedItems.css';
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 [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -15,10 +18,28 @@ export default function FeedItems() {
setError('');
let url = '/api/stream';
+ const params = new URLSearchParams();
+
if (feedId) {
- url = `/api/stream?feed_id=${feedId}`;
+ params.append('feed_id', feedId);
} else if (tagName) {
- url = `/api/stream?tag=${encodeURIComponent(tagName)}`;
+ params.append('tag', tagName);
+ }
+
+ // Apply filters
+ 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
+ params.append('read_filter', 'unread');
+ }
+
+ const queryString = params.toString();
+ if (queryString) {
+ url += `?${queryString}`;
}
fetch(url)
@@ -36,20 +57,133 @@ export default function FeedItems() {
setError(err.message);
setLoading(false);
});
- }, [feedId, tagName]);
+ }, [feedId, tagName, filterFn]);
+
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (items.length === 0) return;
+
+ if (e.key === 'j') {
+ setSelectedIndex((prev) => {
+ const nextIndex = Math.min(prev + 1, items.length - 1);
+ if (nextIndex !== prev) {
+ const item = items[nextIndex];
+ if (!item.read) {
+ markAsRead(item);
+ }
+ scrollToItem(nextIndex);
+ }
+ return nextIndex;
+ });
+ } else if (e.key === 'k') {
+ setSelectedIndex((prev) => {
+ const nextIndex = Math.max(prev - 1, 0);
+ if (nextIndex !== prev) {
+ scrollToItem(nextIndex);
+ }
+ return nextIndex;
+ });
+ } else if (e.key === 's') {
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex >= 0 && currentIndex < items.length) {
+ toggleStar(items[currentIndex]);
+ }
+ return currentIndex;
+ });
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [items]);
+
+ const scrollToItem = (index: number) => {
+ const element = document.getElementById(`item-${index}`);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ };
+
+ const markAsRead = (item: Item) => {
+ const updatedItem = { ...item, read: true };
+ // Optimistic update
+ setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
+
+ fetch(`/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 = (item: Item) => {
+ const updatedItem = { ...item, starred: !item.starred };
+ // Optimistic update
+ setItems((prevItems) => prevItems.map((i) => (i._id === item._id ? updatedItem : i)));
+
+ fetch(`/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));
+ };
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ // If item is not intersecting and is above the viewport, it's been scrolled past
+ if (!entry.isIntersecting && entry.boundingClientRect.top < 0) {
+ const index = Number(entry.target.getAttribute('data-index'));
+ if (!isNaN(index) && index >= 0 && index < items.length) {
+ const item = items[index];
+ if (!item.read) {
+ markAsRead(item);
+ }
+ }
+ }
+ });
+ },
+ { root: null, threshold: 0 }
+ );
+
+ items.forEach((_, index) => {
+ const el = document.getElementById(`item-${index}`);
+ if (el) observer.observe(el);
+ });
+
+ return () => observer.disconnect();
+ }, [items]);
if (loading) return <div className="feed-items-loading">Loading items...</div>;
if (error) return <div className="feed-items-error">Error: {error}</div>;
+ let title = 'Items';
+ if (tagName) title = `Tag: ${tagName}`;
+ else if (feedId) title = 'Feed Items';
+ else if (filterFn === 'starred') title = 'Starred Items';
+ else if (filterFn === 'all') title = 'All Items';
+ else title = 'Unread Items';
+
return (
<div className="feed-items">
- <h2>{tagName ? `Tag: ${tagName}` : 'Items'}</h2>
+ <h2>{title}</h2>
{items.length === 0 ? (
<p>No items found.</p>
) : (
<ul className="item-list">
- {items.map((item) => (
- <FeedItem key={item._id} item={item} />
+ {items.map((item, index) => (
+ <div
+ id={`item-${index}`}
+ key={item._id}
+ data-index={index}
+ className={index === selectedIndex ? 'selected-item-container' : ''}
+ onClick={() => setSelectedIndex(index)}
+ >
+ <FeedItem item={item} />
+ </div>
))}
</ul>
)}
diff --git a/frontend/src/components/FeedList.css b/frontend/src/components/FeedList.css
index 485fab3..b8ca7e6 100644
--- a/frontend/src/components/FeedList.css
+++ b/frontend/src/components/FeedList.css
@@ -80,3 +80,27 @@
background: #dde2e6;
color: #000;
}
+
+.filter-section {
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #eee;
+ margin-bottom: 1rem;
+}
+
+.filter-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ gap: 1rem;
+}
+
+.filter-list li a {
+ text-decoration: none;
+ color: #333;
+ font-weight: 500;
+}
+
+.filter-list li a:hover {
+ color: #007bff;
+}
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
index f17fdc7..d1a4625 100644
--- a/frontend/src/components/FeedList.tsx
+++ b/frontend/src/components/FeedList.tsx
@@ -36,6 +36,13 @@ export default function FeedList() {
return (
<div className="feed-list">
+ <div className="filter-section">
+ <ul className="filter-list">
+ <li><Link to="/?filter=unread">Unread</Link></li>
+ <li><Link to="/?filter=all">All</Link></li>
+ <li><Link to="/?filter=starred">Starred</Link></li>
+ </ul>
+ </div>
<div className="feed-section">
<h2>Feeds</h2>
{feeds.length === 0 ? (
diff --git a/frontend/src/components/TagView.test.tsx b/frontend/src/components/TagView.test.tsx
index 8a724cd..6304fb2 100644
--- a/frontend/src/components/TagView.test.tsx
+++ b/frontend/src/components/TagView.test.tsx
@@ -76,6 +76,9 @@ describe('Tag View Integration', () => {
expect(screen.getByText('Tag Item 1')).toBeInTheDocument();
});
- expect(global.fetch).toHaveBeenCalledWith('/api/stream?tag=Tech');
+ const params = new URLSearchParams();
+ params.append('tag', 'Tech');
+ params.append('read_filter', 'unread');
+ expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`);
});
});
diff --git a/frontend/tests/e2e.spec.ts b/frontend/tests/e2e.spec.ts
index 43b3fa7..7c8c20e 100644
--- a/frontend/tests/e2e.spec.ts
+++ b/frontend/tests/e2e.spec.ts
@@ -33,6 +33,9 @@ test.describe('Neko Reader E2E', () => {
// 5. Navigate to Feed
await page.goto('/v2/');
+ // Default view is now "Unread Items" or "Items", depending on state.
+ // It should NOT show "Select a feed" anymore.
+ await expect(page.getByText('Unread Items')).toBeVisible();
// 6. Verify Tag View
// Go to a tag URL (simulated, since we can't easily add tags via UI in this test yet without setup)