## New Features - Article Summary Mode: AI-generated 30-second summaries with complexity analysis - Reading Stats Dashboard: Track articles read, listening time, and streaks - Bookmark/Resume: Auto-save progress when pausing, resume from where you left off - Audio Export: Export articles as downloadable WAV files - RSS Feed Manager: Subscribe to feeds with real-time validation and 31+ recommendations - Smart Speed: Auto-adjust playback based on article complexity - Voice Moods: Quick presets for different listening scenarios ## RSS Enhancements - Expanded recommendations from 8 to 31 sources across 5 categories: * General News (9 sources) * Technology (8 sources) * Business & Finance (5 sources) * Science & Research (5 sources) * International News (4 sources) - Real-time URL validation with visual feedback - Detailed error messages for different failure scenarios - Always-visible categorized recommendations - Auto-loading articles when feeds are added ## Bug Fixes - Fixed voice selection: Selected voice now consistently applies to playback - Implemented voice generation counter to prevent voice mixing between paragraphs - Fixed speed control to snap to clean 0.5 increments (1.0, 1.5, 2.0, etc.) - Fixed dark mode toggle by configuring Tailwind CDN for class-based dark mode - Removed vibe visualizer animation ## UI/UX Improvements - Redesigned voice selector with prominent voice panel and preview functionality - Added voice cards with emojis and descriptions - Enhanced feature toolbar with quick access to all new features - Improved reader view with better typography and reading modes - Added ambient reading modes (clean, sepia, night light) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
483 lines
22 KiB
TypeScript
483 lines
22 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { RSSFeed, RSSArticle } from '../types';
|
|
import { Rss, Plus, Trash2, RefreshCw, ExternalLink, Loader2, ChevronDown, ChevronUp, AlertCircle, CheckCircle, Star, Filter } from 'lucide-react';
|
|
import { getRSSFeeds, saveRSSFeed, removeRSSFeed } from '../services/storageService';
|
|
import { fetchRSSFeed, refreshFeed, SUGGESTED_FEEDS, validateRSSUrl } from '../services/rssService';
|
|
|
|
interface RSSManagerProps {
|
|
onArticleSelect: (url: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
type FeedStatus = 'idle' | 'validating' | 'valid' | 'invalid' | 'loading';
|
|
|
|
export const RSSManager: React.FC<RSSManagerProps> = ({ onArticleSelect, onClose }) => {
|
|
const [feeds, setFeeds] = useState<RSSFeed[]>([]);
|
|
const [articles, setArticles] = useState<Record<string, RSSArticle[]>>({});
|
|
const [newFeedUrl, setNewFeedUrl] = useState('');
|
|
const [feedStatus, setFeedStatus] = useState<FeedStatus>('idle');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedFeed, setExpandedFeed] = useState<string | null>(null);
|
|
const [loadingFeeds, setLoadingFeeds] = useState<Set<string>>(new Set());
|
|
const [showRecommendations, setShowRecommendations] = useState(true);
|
|
const [filterCategory, setFilterCategory] = useState<'all' | 'news' | 'tech' | 'unread'>('all');
|
|
|
|
useEffect(() => {
|
|
const loadedFeeds = getRSSFeeds();
|
|
setFeeds(loadedFeeds);
|
|
// Auto-load articles for active feeds
|
|
loadedFeeds.forEach(feed => {
|
|
if (feed.isActive) {
|
|
handleRefreshFeed(feed, true);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const validateUrl = async (url: string) => {
|
|
if (!url.trim()) {
|
|
setFeedStatus('idle');
|
|
setError(null);
|
|
return;
|
|
}
|
|
|
|
setFeedStatus('validating');
|
|
setError(null);
|
|
|
|
try {
|
|
// Check if it looks like a URL
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
setError('URL must start with http:// or https://');
|
|
setFeedStatus('invalid');
|
|
return;
|
|
}
|
|
|
|
const isValid = await validateRSSUrl(url);
|
|
if (isValid) {
|
|
setFeedStatus('valid');
|
|
setError(null);
|
|
} else {
|
|
setFeedStatus('invalid');
|
|
setError('Not a valid RSS feed. Make sure the URL points to an RSS/Atom feed XML file.');
|
|
}
|
|
} catch (e) {
|
|
setFeedStatus('invalid');
|
|
setError('Could not access URL. Check the address and try again.');
|
|
}
|
|
};
|
|
|
|
// Debounced validation
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
if (newFeedUrl && feedStatus !== 'loading') {
|
|
validateUrl(newFeedUrl);
|
|
}
|
|
}, 800);
|
|
return () => clearTimeout(timer);
|
|
}, [newFeedUrl]);
|
|
|
|
const handleAddFeed = async (url: string) => {
|
|
if (!url.trim()) return;
|
|
|
|
setFeedStatus('loading');
|
|
setError(null);
|
|
|
|
try {
|
|
const { feed, articles: feedArticles } = await fetchRSSFeed(url.trim());
|
|
|
|
if (feedArticles.length === 0) {
|
|
setError(`Feed added but contains 0 articles. The feed might be empty or improperly formatted.`);
|
|
}
|
|
|
|
saveRSSFeed(feed);
|
|
setFeeds(prev => [...prev, feed]);
|
|
setArticles(prev => ({ ...prev, [feed.id]: feedArticles }));
|
|
setNewFeedUrl('');
|
|
setFeedStatus('idle');
|
|
setExpandedFeed(feed.id);
|
|
} catch (e: any) {
|
|
setFeedStatus('invalid');
|
|
if (e.message?.includes('Failed to fetch')) {
|
|
setError('Network error: Could not reach the feed URL. Check your connection or try a different feed.');
|
|
} else if (e.message?.includes('status')) {
|
|
setError(`Server error: The feed URL returned an error. It might be down or require authentication.`);
|
|
} else {
|
|
setError(e.message || 'Failed to add feed. Make sure it\'s a valid RSS/Atom feed URL.');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRefreshFeed = async (feed: RSSFeed, silent = false) => {
|
|
if (!silent) {
|
|
setLoadingFeeds(prev => new Set(prev).add(feed.id));
|
|
}
|
|
|
|
try {
|
|
const feedArticles = await refreshFeed(feed);
|
|
setArticles(prev => ({ ...prev, [feed.id]: feedArticles }));
|
|
|
|
// Update article count
|
|
const updatedFeed = { ...feed, articleCount: feedArticles.length };
|
|
saveRSSFeed(updatedFeed);
|
|
setFeeds(prev => prev.map(f => f.id === feed.id ? updatedFeed : f));
|
|
} catch (e) {
|
|
console.error('Failed to refresh feed:', e);
|
|
} finally {
|
|
if (!silent) {
|
|
setLoadingFeeds(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(feed.id);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemoveFeed = (feedId: string) => {
|
|
removeRSSFeed(feedId);
|
|
setFeeds(prev => prev.filter(f => f.id !== feedId));
|
|
setArticles(prev => {
|
|
const next = { ...prev };
|
|
delete next[feedId];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleArticleClick = (url: string) => {
|
|
onArticleSelect(url);
|
|
onClose();
|
|
};
|
|
|
|
const formatDate = (dateStr?: string): string => {
|
|
if (!dateStr) return '';
|
|
try {
|
|
return new Date(dateStr).toLocaleDateString('en', {
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const getStatusIcon = () => {
|
|
switch (feedStatus) {
|
|
case 'validating':
|
|
return <Loader2 className="w-4 h-4 animate-spin text-blue-500" />;
|
|
case 'valid':
|
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
|
case 'invalid':
|
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Categorize suggested feeds by the category field
|
|
const categorizedSuggestions = {
|
|
news: SUGGESTED_FEEDS.filter(f => f.category === 'news'),
|
|
tech: SUGGESTED_FEEDS.filter(f => f.category === 'tech'),
|
|
business: SUGGESTED_FEEDS.filter(f => f.category === 'business'),
|
|
science: SUGGESTED_FEEDS.filter(f => f.category === 'science'),
|
|
international: SUGGESTED_FEEDS.filter(f => f.category === 'international')
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-slate-200 dark:border-slate-800">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
|
<Rss className="w-6 h-6 text-orange-500" />
|
|
RSS Feed Manager
|
|
</h2>
|
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
|
Subscribe to your favorite news sources
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
|
>
|
|
<span className="text-slate-500 text-xl">×</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Feed Input */}
|
|
<div className="space-y-2">
|
|
<div className="flex gap-2">
|
|
<div className="flex-grow relative">
|
|
<input
|
|
type="url"
|
|
value={newFeedUrl}
|
|
onChange={(e) => setNewFeedUrl(e.target.value)}
|
|
placeholder="Enter RSS feed URL (e.g., https://example.com/feed.xml)"
|
|
className="w-full px-4 py-3 pr-10 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl text-slate-700 dark:text-slate-200 placeholder:text-slate-400"
|
|
onKeyDown={(e) => e.key === 'Enter' && feedStatus === 'valid' && handleAddFeed(newFeedUrl)}
|
|
/>
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
{getStatusIcon()}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleAddFeed(newFeedUrl)}
|
|
disabled={feedStatus === 'loading' || feedStatus === 'invalid' || !newFeedUrl.trim()}
|
|
className="px-6 py-3 bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl font-medium flex items-center gap-2 transition-all"
|
|
>
|
|
{feedStatus === 'loading' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
|
Add
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{feedStatus === 'valid' && (
|
|
<div className="flex items-center gap-2 p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
|
<p className="text-sm text-green-700 dark:text-green-300">Valid RSS feed detected</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-grow overflow-y-auto p-6 space-y-6">
|
|
{/* My Feeds */}
|
|
{feeds.length > 0 && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-2">
|
|
<Star className="w-4 h-4 text-orange-500" />
|
|
My Feeds ({feeds.length})
|
|
</h3>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{feeds.map((feed) => {
|
|
const feedArticles = articles[feed.id] || [];
|
|
return (
|
|
<div
|
|
key={feed.id}
|
|
className="border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden bg-white dark:bg-slate-900"
|
|
>
|
|
{/* Feed Header */}
|
|
<div
|
|
className="p-4 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
|
onClick={() => setExpandedFeed(expandedFeed === feed.id ? null : feed.id)}
|
|
>
|
|
<div className="flex items-center gap-3 flex-grow min-w-0">
|
|
<Rss className="w-5 h-5 text-orange-500 flex-shrink-0" />
|
|
<div className="min-w-0 flex-grow">
|
|
<p className="font-medium text-slate-800 dark:text-slate-200 truncate">{feed.title}</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
|
{feedArticles.length > 0 ? `${feedArticles.length} articles` : 'Click refresh to load articles'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRefreshFeed(feed);
|
|
}}
|
|
className="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
|
title="Refresh feed"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 text-slate-500 ${loadingFeeds.has(feed.id) ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemoveFeed(feed.id);
|
|
}}
|
|
className="p-2 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
|
title="Remove feed"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-red-500" />
|
|
</button>
|
|
{expandedFeed === feed.id ? (
|
|
<ChevronUp className="w-5 h-5 text-slate-400" />
|
|
) : (
|
|
<ChevronDown className="w-5 h-5 text-slate-400" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Articles List */}
|
|
{expandedFeed === feed.id && (
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{feedArticles.length > 0 ? (
|
|
feedArticles.slice(0, 20).map((article, i) => (
|
|
<div
|
|
key={i}
|
|
onClick={() => handleArticleClick(article.url)}
|
|
className="p-4 border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer transition-colors group"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-grow min-w-0">
|
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 line-clamp-2 group-hover:text-orange-600 dark:group-hover:text-orange-400 transition-colors">
|
|
{article.title}
|
|
</p>
|
|
{article.description && (
|
|
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 mt-1">
|
|
{article.description}
|
|
</p>
|
|
)}
|
|
{article.pubDate && (
|
|
<p className="text-xs text-slate-400 mt-1">{formatDate(article.pubDate)}</p>
|
|
)}
|
|
</div>
|
|
<ExternalLink className="w-4 h-4 text-slate-400 group-hover:text-orange-500 flex-shrink-0 transition-colors" />
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="p-6 text-center text-slate-500 dark:text-slate-400 text-sm">
|
|
<AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
|
<p>No articles loaded yet</p>
|
|
<button
|
|
onClick={() => handleRefreshFeed(feed)}
|
|
className="mt-2 text-orange-500 hover:underline text-sm font-medium"
|
|
>
|
|
Click to refresh
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recommendations - Always Visible */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
|
|
Recommended Feeds
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowRecommendations(!showRecommendations)}
|
|
className="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
|
>
|
|
{showRecommendations ? 'Hide' : 'Show'}
|
|
</button>
|
|
</div>
|
|
|
|
{showRecommendations && (
|
|
<div className="space-y-4">
|
|
{/* News Category */}
|
|
<div>
|
|
<h4 className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">General News</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{categorizedSuggestions.news.map((suggestion) => (
|
|
<button
|
|
key={suggestion.url}
|
|
onClick={() => handleAddFeed(suggestion.url)}
|
|
disabled={feedStatus === 'loading' || feeds.some(f => f.url === suggestion.url)}
|
|
className="p-3 text-left bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<p className="font-medium text-slate-700 dark:text-slate-200 text-sm flex items-center gap-2">
|
|
{feeds.some(f => f.url === suggestion.url) && <CheckCircle className="w-3 h-3 text-green-500" />}
|
|
{suggestion.name}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Technology Category */}
|
|
<div>
|
|
<h4 className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Technology</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{categorizedSuggestions.tech.map((suggestion) => (
|
|
<button
|
|
key={suggestion.url}
|
|
onClick={() => handleAddFeed(suggestion.url)}
|
|
disabled={feedStatus === 'loading' || feeds.some(f => f.url === suggestion.url)}
|
|
className="p-3 text-left bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<p className="font-medium text-slate-700 dark:text-slate-200 text-sm flex items-center gap-2">
|
|
{feeds.some(f => f.url === suggestion.url) && <CheckCircle className="w-3 h-3 text-green-500" />}
|
|
{suggestion.name}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Business & Finance Category */}
|
|
<div>
|
|
<h4 className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Business & Finance</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{categorizedSuggestions.business.map((suggestion) => (
|
|
<button
|
|
key={suggestion.url}
|
|
onClick={() => handleAddFeed(suggestion.url)}
|
|
disabled={feedStatus === 'loading' || feeds.some(f => f.url === suggestion.url)}
|
|
className="p-3 text-left bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<p className="font-medium text-slate-700 dark:text-slate-200 text-sm flex items-center gap-2">
|
|
{feeds.some(f => f.url === suggestion.url) && <CheckCircle className="w-3 h-3 text-green-500" />}
|
|
{suggestion.name}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Science Category */}
|
|
<div>
|
|
<h4 className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Science & Research</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{categorizedSuggestions.science.map((suggestion) => (
|
|
<button
|
|
key={suggestion.url}
|
|
onClick={() => handleAddFeed(suggestion.url)}
|
|
disabled={feedStatus === 'loading' || feeds.some(f => f.url === suggestion.url)}
|
|
className="p-3 text-left bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<p className="font-medium text-slate-700 dark:text-slate-200 text-sm flex items-center gap-2">
|
|
{feeds.some(f => f.url === suggestion.url) && <CheckCircle className="w-3 h-3 text-green-500" />}
|
|
{suggestion.name}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* International Category */}
|
|
<div>
|
|
<h4 className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">International News</h4>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{categorizedSuggestions.international.map((suggestion) => (
|
|
<button
|
|
key={suggestion.url}
|
|
onClick={() => handleAddFeed(suggestion.url)}
|
|
disabled={feedStatus === 'loading' || feeds.some(f => f.url === suggestion.url)}
|
|
className="p-3 text-left bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-slate-200 dark:border-slate-700"
|
|
>
|
|
<p className="font-medium text-slate-700 dark:text-slate-200 text-sm flex items-center gap-2">
|
|
{feeds.some(f => f.url === suggestion.url) && <CheckCircle className="w-3 h-3 text-green-500" />}
|
|
{suggestion.name}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|