{/* Navigation */}
@@ -245,6 +325,7 @@ export function ArticleDetailPage() {
{article.author && By {article.author}}
{formatRelativeTime(new Date(article.publishedAt))}
+ {readingTime}
{article.readAt && (
✓ Read
)}
@@ -266,6 +347,11 @@ export function ArticleDetailPage() {
)}
+ {/* Podcast Player */}
+ {article.enclosureUrl && article.enclosureType?.startsWith('audio/') && (
+
+ )}
+
{/* Featured Image */}
{article.imageUrl && (
@@ -293,8 +379,61 @@ export function ArticleDetailPage() {
)}
+ {/* Annotation mode banner */}
+ {isAnnotating && (
+
+ Annotation mode: select text in the article to highlight it.
+
+ )}
+
+ {/* Annotation creation popover */}
+ {pendingSelection && (
+
+
+ Selected: “{pendingSelection.text.slice(0, 80)}{pendingSelection.text.length > 80 ? '…' : ''}”
+
+
+ {(['yellow', 'green', 'blue', 'pink'] as const).map((c) => (
+
+
+ )}
+
{/* Article Content (rendered as segments to support inline translations) */}
-
+
{segments.map((segment, index) => (
@@ -308,6 +447,27 @@ export function ArticleDetailPage() {
))}
+ {/* Saved Annotations */}
+ {annotations.length > 0 && (
+
+
Highlights & Annotations
+
+ {annotations.map((ann) => (
+ -
+
“{ann.selectedText}”
+ {ann.note && {ann.note}
}
+
+
+ ))}
+
+
+ )}
+
{/* Original Article Link + Load Full Content */}
{article.link && (
);
diff --git a/src/pages/FeedDetailPage.tsx b/src/pages/FeedDetailPage.tsx
index 3f6e50d..750d2f9 100644
--- a/src/pages/FeedDetailPage.tsx
+++ b/src/pages/FeedDetailPage.tsx
@@ -3,13 +3,17 @@
* Shows article list with read/unread status, mark-as-read, and favorites
*/
-import { useCallback, useState, useEffect } from 'react';
+import { useCallback, useState, useEffect, useRef } from 'react';
import { useLoaderData, useNavigate, Link } from 'react-router-dom';
import { ArrowLeft, Heart, RefreshCw } from 'lucide-react';
import { useStore } from '@hooks/useStore';
+import { useKeyboardShortcuts } from '@hooks/useKeyboardShortcuts';
import { formatRelativeTime } from '@utils/dateFormat';
import { fetchAndStoreArticles, getArticlesForFeed } from '@services/feedService';
import { storage } from '@lib/storage';
+import { KeyboardShortcutsHelp } from '@components/Common/KeyboardShortcutsHelp';
+import { ArticleCardSkeleton } from '@components/Common/Skeleton';
+import { discoverFeeds } from '@utils/feedDiscovery';
import type { Feed, Article } from '@/models';
interface FeedDetailLoaderData {
@@ -18,20 +22,50 @@ interface FeedDetailLoaderData {
isOffline: boolean;
}
+const FALLBACK_FEEDS = [
+ { url: 'https://feeds.feedburner.com/TechCrunch', title: 'TechCrunch' },
+ { url: 'https://www.theverge.com/rss/index.xml', title: 'The Verge' },
+ { url: 'https://hnrss.org/frontpage', title: 'Hacker News' },
+ { url: 'https://feeds.arstechnica.com/arstechnica/index', title: 'Ars Technica' },
+ { url: 'https://www.wired.com/feed/rss', title: 'Wired' },
+];
+
export function FeedDetailPage() {
const loaderData = useLoaderData() as FeedDetailLoaderData;
const navigate = useNavigate();
- const { toggleArticleFavorite } = useStore();
+ const { toggleArticleFavorite, subscribeFeed } = useStore();
const [articles, setArticles] = useState
(loaderData.articles);
const [feed, setFeed] = useState(loaderData.feed);
const [isRefreshing, setIsRefreshing] = useState(false);
+ const [isLoadingArticles, setIsLoadingArticles] = useState(loaderData.articles.length === 0);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [showHelp, setShowHelp] = useState(false);
+ const [suggestedFeeds, setSuggestedFeeds] = useState<{ url: string; title: string }[]>([]);
+ const [subscribingUrl, setSubscribingUrl] = useState(null);
// Sync with loader data when navigating to a different feed
useEffect(() => {
setArticles(loaderData.articles);
setFeed(loaderData.feed);
+ setIsLoadingArticles(loaderData.articles.length === 0);
}, [loaderData]);
+ useEffect(() => {
+ if (!feed.link) {
+ setSuggestedFeeds(FALLBACK_FEEDS.slice(0, 5));
+ return;
+ }
+ discoverFeeds(feed.link).then((discovered) => {
+ setSuggestedFeeds(discovered.length > 0 ? discovered.slice(0, 5) : FALLBACK_FEEDS.slice(0, 5));
+ });
+ }, [feed.id, feed.link]);
+
+ const handleSubscribeSuggested = useCallback(async (url: string) => {
+ setSubscribingUrl(url);
+ await subscribeFeed(url);
+ setSubscribingUrl(null);
+ }, [subscribeFeed]);
+
const [refreshError, setRefreshError] = useState(null);
const handleRefresh = useCallback(async () => {
@@ -68,8 +102,27 @@ export function FeedDetailPage() {
const unreadCount = sortedArticles.filter(a => !a.readAt).length;
+ const selectedIndexRef = useRef(selectedIndex);
+ selectedIndexRef.current = selectedIndex;
+
+ useKeyboardShortcuts({
+ j: () => setSelectedIndex((i) => Math.min(i + 1, sortedArticles.length - 1)),
+ k: () => setSelectedIndex((i) => Math.max(i - 1, 0)),
+ o: () => {
+ const article = sortedArticles[selectedIndexRef.current];
+ if (article) navigate(`/articles/${article.id}`);
+ },
+ Enter: () => {
+ const article = sortedArticles[selectedIndexRef.current];
+ if (article) navigate(`/articles/${article.id}`);
+ },
+ '?': () => setShowHelp((v) => !v),
+ Escape: () => navigate('/feeds'),
+ });
+
return (
+ {showHelp &&
setShowHelp(false)} />}
{/* Header */}
+ ) : sortedArticles.length === 0 ? (
No articles yet
@@ -134,12 +193,13 @@ export function FeedDetailPage() {
) : (
- {sortedArticles.map((article) => {
+ {sortedArticles.map((article, index) => {
const isUnread = !article.readAt;
+ const isSelected = index === selectedIndex;
return (
{/* Unread Indicator */}
@@ -198,6 +258,29 @@ export function FeedDetailPage() {
})}
)}
+ {/* Suggested Feeds */}
+ {suggestedFeeds.length > 0 && (
+
+
Suggested Feeds
+
+ {suggestedFeeds.map((sf) => (
+
+
+
{sf.title}
+
{sf.url}
+
+
+
+ ))}
+
+
+ )}
);
}
diff --git a/src/pages/FeedsPage.tsx b/src/pages/FeedsPage.tsx
index fce9686..4a06c91 100644
--- a/src/pages/FeedsPage.tsx
+++ b/src/pages/FeedsPage.tsx
@@ -13,6 +13,7 @@ import { useStore } from '@hooks/useStore';
import { storage } from '@lib/storage';
import { syncService } from '@services/syncService';
import { AddFeedDialog } from '@components/AddFeedDialog/AddFeedDialog';
+import { FeedCardSkeleton } from '@components/Common/Skeleton';
import type { Article } from '@models/Feed';
export function FeedsPage() {
@@ -186,8 +187,10 @@ export function FeedsPage() {
{/* Loading */}
{isLoading && !feeds.length && (
-
-
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
)}
diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx
index 49a2efc..99dba50 100644
--- a/src/pages/SearchPage.tsx
+++ b/src/pages/SearchPage.tsx
@@ -1,15 +1,20 @@
/**
* Search Page
* Secondary page with back button, search input, and results
- * Searches across feed titles and article titles/summaries
+ * Searches across feed titles and article titles/summaries with advanced filters
*/
-import { useState, useCallback, useEffect } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { ArrowLeft, Search as SearchIcon, Rss, FileText } from 'lucide-react';
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import { Link, useNavigate, useSearchParams } from 'react-router-dom';
+import { ArrowLeft, Search as SearchIcon, Rss, FileText, SlidersHorizontal, X } from 'lucide-react';
import { storage } from '@lib/storage';
import type { Feed, Article } from '@models/Feed';
+const MS_PER_DAY = 86_400_000;
+
+type DateFilter = 'all' | 'today' | '7days' | '30days';
+type ReadStatus = 'all' | 'unread' | 'read';
+
interface SearchResults {
feeds: Feed[];
articles: (Article & { feedTitle?: string })[];
@@ -17,11 +22,59 @@ interface SearchResults {
export function SearchPage() {
const navigate = useNavigate();
- const [query, setQuery] = useState('');
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const [query, setQuery] = useState(() => searchParams.get('q') ?? '');
+ const [showFilters, setShowFilters] = useState(false);
+ const [feedFilter, setFeedFilter] = useState
(() =>
+ searchParams.getAll('feed')
+ );
+ const [dateFilter, setDateFilter] = useState(
+ () => (searchParams.get('date') as DateFilter) ?? 'all'
+ );
+ const [readStatus, setReadStatus] = useState(
+ () => (searchParams.get('read') as ReadStatus) ?? 'all'
+ );
+ const [starredOnly, setStarredOnly] = useState(() => searchParams.get('starred') === '1');
+
+ const [allFeeds, setAllFeeds] = useState([]);
const [results, setResults] = useState({ feeds: [], articles: [] });
const [hasSearched, setHasSearched] = useState(false);
- const performSearch = useCallback(async (searchQuery: string) => {
+ // Load all feeds once for the filter list
+ useEffect(() => {
+ storage.init().catch(() => {}).then(() =>
+ storage.getAll('feeds').then((feeds) => setAllFeeds(feeds.filter((f) => !f.deletedAt)))
+ );
+ }, []);
+
+ // Sync state → URL params
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (query) params.set('q', query);
+ feedFilter.forEach((id) => params.append('feed', id));
+ if (dateFilter !== 'all') params.set('date', dateFilter);
+ if (readStatus !== 'all') params.set('read', readStatus);
+ if (starredOnly) params.set('starred', '1');
+ setSearchParams(params, { replace: true });
+ }, [query, feedFilter, dateFilter, readStatus, starredOnly, setSearchParams]);
+
+ const hasActiveFilters = feedFilter.length > 0 || dateFilter !== 'all' || readStatus !== 'all' || starredOnly;
+
+ const clearFilters = useCallback(() => {
+ setFeedFilter([]);
+ setDateFilter('all');
+ setReadStatus('all');
+ setStarredOnly(false);
+ }, []);
+
+ const performSearch = useCallback(async (
+ searchQuery: string,
+ feeds: string[],
+ date: DateFilter,
+ read: ReadStatus,
+ starred: boolean,
+ ) => {
if (!searchQuery.trim()) {
setResults({ feeds: [], articles: [] });
setHasSearched(false);
@@ -34,28 +87,39 @@ export function SearchPage() {
try {
await storage.init().catch(() => {});
- // Search feeds
- const allFeeds = (await storage.getAll('feeds')).filter(f => !f.deletedAt);
- const matchedFeeds = allFeeds.filter(
+ const allFeedsData = (await storage.getAll('feeds')).filter((f) => !f.deletedAt);
+ const matchedFeeds = allFeedsData.filter(
(f) =>
f.title.toLowerCase().includes(q) ||
f.description?.toLowerCase().includes(q) ||
f.url.toLowerCase().includes(q)
);
- // Search articles
- const allArticles = (await storage.getAll('articles')).filter(a => !a.deletedAt);
+ const allArticles = (await storage.getAll('articles')).filter((a) => !a.deletedAt);
+ const now = Date.now();
+ const feedMap = new Map(allFeedsData.map((f) => [f.id, f.title]));
+
const matchedArticles = allArticles
- .filter(
- (a) =>
- a.title.toLowerCase().includes(q) ||
- a.summary?.toLowerCase().includes(q)
- )
- .slice(0, 50); // Limit results
-
- // Attach feed titles to articles
- const feedMap = new Map(allFeeds.map(f => [f.id, f.title]));
- const articlesWithFeedTitle = matchedArticles.map(a => ({
+ .filter((a) => a.title.toLowerCase().includes(q) || a.summary?.toLowerCase().includes(q))
+ .filter((a) => (feeds.length === 0 ? true : feeds.includes(a.feedId)))
+ .filter((a) => {
+ if (date === 'all') return true;
+ const ms = now - new Date(a.publishedAt).getTime();
+ if (date === 'today') return ms < MS_PER_DAY;
+ if (date === '7days') return ms < 7 * MS_PER_DAY;
+ if (date === '30days') return ms < 30 * MS_PER_DAY;
+ return true;
+ })
+ .filter((a) => {
+ if (read === 'all') return true;
+ if (read === 'unread') return !a.readAt;
+ if (read === 'read') return !!a.readAt;
+ return true;
+ })
+ .filter((a) => (starred ? a.isFavorite : true))
+ .slice(0, 50);
+
+ const articlesWithFeedTitle = matchedArticles.map((a) => ({
...a,
feedTitle: feedMap.get(a.feedId),
}));
@@ -66,18 +130,31 @@ export function SearchPage() {
}
}, []);
- // Debounced search
+ // Debounced search with filters
useEffect(() => {
const timer = setTimeout(() => {
- performSearch(query);
+ performSearch(query, feedFilter, dateFilter, readStatus, starredOnly);
}, 300);
return () => clearTimeout(timer);
- }, [query, performSearch]);
+ }, [query, feedFilter, dateFilter, readStatus, starredOnly, performSearch]);
+
+ const DATE_OPTIONS: { label: string; value: DateFilter }[] = useMemo(() => [
+ { label: 'All', value: 'all' },
+ { label: 'Today', value: 'today' },
+ { label: '7 days', value: '7days' },
+ { label: '30 days', value: '30days' },
+ ], []);
+
+ const READ_OPTIONS: { label: string; value: ReadStatus }[] = useMemo(() => [
+ { label: 'All', value: 'all' },
+ { label: 'Unread', value: 'unread' },
+ { label: 'Read', value: 'read' },
+ ], []);
return (
{/* Search Header */}
-
+
+
+ {/* Filter Bar */}
+ {showFilters && (
+
+ {/* Feed filter */}
+ {allFeeds.length > 0 && (
+
+
Feeds
+
+ {allFeeds.map((feed) => (
+
+ ))}
+
+
+ )}
+
+ {/* Date filter */}
+
+
Date
+
+ {DATE_OPTIONS.map(({ label, value }) => (
+
+ ))}
+
+
+
+ {/* Read status filter */}
+
+
Status
+
+ {READ_OPTIONS.map(({ label, value }) => (
+
+ ))}
+
+
+
+ {/* Starred toggle + clear */}
+
+
+ {hasActiveFilters && (
+
+ )}
+
+
+ )}
+
{/* Results */}
{hasSearched && results.feeds.length === 0 && results.articles.length === 0 && (
@@ -171,3 +347,4 @@ export function SearchPage() {
);
}
+
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 34bd1a1..366902b 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -18,12 +18,39 @@ const DEFAULT_SETTINGS: UserSettings = {
enableBackgroundSync: true,
};
+const SYNC_CONFIG_KEY = 'rss-reader-sync-config';
+const NEWSLETTER_ID_KEY = 'rss-reader-newsletter-id';
+
+function getOrCreateNewsletterId(): string {
+ let id = localStorage.getItem(NEWSLETTER_ID_KEY);
+ if (!id) {
+ id = crypto.randomUUID();
+ localStorage.setItem(NEWSLETTER_ID_KEY, id);
+ }
+ return id;
+}
+
+interface SyncConfig {
+ enabled: boolean;
+ serverUrl: string;
+ apiKey: string;
+}
+
export default function Settings() {
const { t } = useTranslation('settings');
const [settings, setSettings] = useState
(DEFAULT_SETTINGS);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
+ // Sync section state
+ const [syncConfig, setSyncConfig] = useState({ enabled: false, serverUrl: '', apiKey: '' });
+ const [syncToast, setSyncToast] = useState('');
+
+ // Newsletter section state
+ const [newsletterId] = useState(() => getOrCreateNewsletterId());
+ const [copied, setCopied] = useState(false);
+ const newsletterEmail = `${newsletterId}@newsletters.rss-reader.app`;
+
// Load settings on mount
useEffect(() => {
const loadSettings = async () => {
@@ -33,6 +60,12 @@ export default function Settings() {
}
};
loadSettings();
+
+ // Load sync config
+ const raw = localStorage.getItem(SYNC_CONFIG_KEY);
+ if (raw) {
+ try { setSyncConfig(JSON.parse(raw)); } catch { /* ignore */ }
+ }
}, []);
const handleRefreshIntervalChange = async (minutes: number) => {
@@ -178,6 +211,101 @@ export default function Settings() {
+
+ {/* Sync Section */}
+
+
Sync
+
+ {syncConfig.enabled && (
+
+ )}
+
+
+
+
+ {syncToast && (
+
{syncToast}
+ )}
+
+
+ {/* Newsletter Section */}
+
+
Newsletter
+
+ Your unique forwarding address:
+
+
+
+ {newsletterEmail}
+
+
+
+
+ Forward newsletters to this address to read them here. Backend sync required.
+
+
{/* Save Indicator */}
diff --git a/src/utils/feedDiscovery.ts b/src/utils/feedDiscovery.ts
new file mode 100644
index 0000000..63ee4c8
--- /dev/null
+++ b/src/utils/feedDiscovery.ts
@@ -0,0 +1,32 @@
+/**
+ * Feed discovery utility
+ * Autodiscovers RSS/Atom feeds from a website URL by parsing tags.
+ */
+
+export async function discoverFeeds(websiteUrl: string): Promise<{ url: string; title: string }[]> {
+ try {
+ const response = await fetch(websiteUrl, { signal: AbortSignal.timeout(8000) });
+ if (!response.ok) return [];
+ const html = await response.text();
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(html, 'text/html');
+ const linkEls = doc.querySelectorAll(
+ 'link[rel="alternate"][type="application/rss+xml"], link[rel="alternate"][type="application/atom+xml"]',
+ );
+ const results: { url: string; title: string }[] = [];
+ linkEls.forEach((el) => {
+ const href = el.getAttribute('href');
+ if (!href) return;
+ const title = el.getAttribute('title') || href;
+ try {
+ const url = new URL(href, websiteUrl).href;
+ results.push({ url, title });
+ } catch {
+ // skip invalid URLs
+ }
+ });
+ return results;
+ } catch {
+ return [];
+ }
+}
diff --git a/src/utils/readingTime.ts b/src/utils/readingTime.ts
new file mode 100644
index 0000000..e39888a
--- /dev/null
+++ b/src/utils/readingTime.ts
@@ -0,0 +1,27 @@
+/**
+ * Reading Time Estimation Utilities
+ */
+
+/**
+ * Calculate estimated reading time in minutes.
+ * Strips HTML tags, counts words at 200 wpm, minimum 1 minute.
+ */
+export function calculateReadingTime(html: string): number {
+ const text = html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ if (!text) return 1;
+ const words = text.split(' ').filter(Boolean).length;
+ return Math.max(1, Math.ceil(words / 200));
+}
+
+/**
+ * Format reading time as a human-readable string.
+ * @param minutes - estimated reading time in minutes
+ * @param lang - language code (e.g. 'zh', 'zh-CN'); defaults to English
+ */
+export function formatReadingTime(minutes: number, lang?: string): string {
+ const isChinese = lang && lang.startsWith('zh');
+ if (isChinese) {
+ return `${minutes} 分钟阅读`;
+ }
+ return `${minutes} min read`;
+}
diff --git a/tests/unit/readingTime.test.ts b/tests/unit/readingTime.test.ts
new file mode 100644
index 0000000..4927bf3
--- /dev/null
+++ b/tests/unit/readingTime.test.ts
@@ -0,0 +1,58 @@
+/**
+ * Unit tests for readingTime utilities
+ */
+
+import { describe, it, expect } from 'vitest';
+import { calculateReadingTime, formatReadingTime } from '@utils/readingTime';
+
+describe('calculateReadingTime', () => {
+ it('returns 1 for empty input', () => {
+ expect(calculateReadingTime('')).toBe(1);
+ });
+
+ it('returns 1 for whitespace-only input', () => {
+ expect(calculateReadingTime(' ')).toBe(1);
+ });
+
+ it('strips HTML tags before counting words', () => {
+ const html = 'Hello world
';
+ expect(calculateReadingTime(html)).toBe(1);
+ });
+
+ it('returns 1 for exactly 200 words', () => {
+ const words = Array(200).fill('word').join(' ');
+ expect(calculateReadingTime(words)).toBe(1);
+ });
+
+ it('returns 2 for 201 words', () => {
+ const words = Array(201).fill('word').join(' ');
+ expect(calculateReadingTime(words)).toBe(2);
+ });
+
+ it('handles complex HTML with nested tags', () => {
+ const html = '' + Array(400).fill('- word
').join('') + '
';
+ expect(calculateReadingTime(html)).toBe(2);
+ });
+});
+
+describe('formatReadingTime', () => {
+ it('returns English format by default', () => {
+ expect(formatReadingTime(5)).toBe('5 min read');
+ });
+
+ it('returns English format when lang is undefined', () => {
+ expect(formatReadingTime(3, undefined)).toBe('3 min read');
+ });
+
+ it('returns Chinese format for zh lang', () => {
+ expect(formatReadingTime(5, 'zh')).toBe('5 分钟阅读');
+ });
+
+ it('returns Chinese format for zh-CN lang', () => {
+ expect(formatReadingTime(5, 'zh-CN')).toBe('5 分钟阅读');
+ });
+
+ it('returns English format for en lang', () => {
+ expect(formatReadingTime(10, 'en')).toBe('10 min read');
+ });
+});