Major feature update: Enhanced RSS, voice fixes, dark mode, and UI improvements
## 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>
This commit is contained in:
128
components/BookmarksPanel.tsx
Normal file
128
components/BookmarksPanel.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Bookmark } from '../types';
|
||||
import { Bookmark as BookmarkIcon, Play, Trash2, Clock } from 'lucide-react';
|
||||
import { getBookmarks, removeBookmark } from '../services/storageService';
|
||||
|
||||
interface BookmarksPanelProps {
|
||||
onResumeArticle: (url: string, segmentIndex: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BookmarksPanel: React.FC<BookmarksPanelProps> = ({
|
||||
onResumeArticle,
|
||||
onClose
|
||||
}) => {
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarks(getBookmarks());
|
||||
}, []);
|
||||
|
||||
const handleRemove = (url: string) => {
|
||||
removeBookmark(url);
|
||||
setBookmarks(prev => prev.filter(b => b.url !== url));
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
return date.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
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-lg 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">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<BookmarkIcon className="w-6 h-6 text-yellow-500" />
|
||||
Bookmarks
|
||||
</h2>
|
||||
<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>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-2">
|
||||
Resume where you left off
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bookmarks List */}
|
||||
<div className="flex-grow overflow-y-auto p-4 space-y-3">
|
||||
{bookmarks.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
<BookmarkIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No bookmarks yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
Articles are automatically bookmarked when you pause
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
bookmarks.map((bookmark) => (
|
||||
<div
|
||||
key={bookmark.url}
|
||||
className="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-4 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-grow min-w-0">
|
||||
<h3 className="font-medium text-slate-800 dark:text-slate-200 truncate">
|
||||
{bookmark.title || 'Untitled'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
|
||||
{bookmark.url}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDate(bookmark.savedAt)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-blue-500">
|
||||
{bookmark.progress}% complete
|
||||
</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-1.5 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${bookmark.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onResumeArticle(bookmark.url, bookmark.segmentIndex);
|
||||
onClose();
|
||||
}}
|
||||
className="p-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
|
||||
title="Resume"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(bookmark.url)}
|
||||
className="p-2 hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500 rounded-lg transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
components/FeatureToolbar.tsx
Normal file
94
components/FeatureToolbar.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
Rss,
|
||||
Bookmark,
|
||||
Download,
|
||||
Zap,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FeatureToolbarProps {
|
||||
onOpenStats: () => void;
|
||||
onOpenRSS: () => void;
|
||||
onOpenBookmarks: () => void;
|
||||
onExportAudio: () => void;
|
||||
smartSpeedEnabled: boolean;
|
||||
onToggleSmartSpeed: () => void;
|
||||
hasBookmark?: boolean;
|
||||
canExport?: boolean;
|
||||
isExporting?: boolean;
|
||||
}
|
||||
|
||||
export const FeatureToolbar: React.FC<FeatureToolbarProps> = ({
|
||||
onOpenStats,
|
||||
onOpenRSS,
|
||||
onOpenBookmarks,
|
||||
onExportAudio,
|
||||
smartSpeedEnabled,
|
||||
onToggleSmartSpeed,
|
||||
hasBookmark,
|
||||
canExport,
|
||||
isExporting
|
||||
}) => {
|
||||
const buttons = [
|
||||
{
|
||||
icon: BarChart3,
|
||||
label: 'Stats',
|
||||
onClick: onOpenStats,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/30'
|
||||
},
|
||||
{
|
||||
icon: Rss,
|
||||
label: 'RSS Feeds',
|
||||
onClick: onOpenRSS,
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-900/30'
|
||||
},
|
||||
{
|
||||
icon: Bookmark,
|
||||
label: 'Bookmarks',
|
||||
onClick: onOpenBookmarks,
|
||||
color: hasBookmark ? 'text-yellow-500' : 'text-slate-500',
|
||||
bgColor: hasBookmark ? 'bg-yellow-50 dark:bg-yellow-900/30' : 'bg-slate-50 dark:bg-slate-800'
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: isExporting ? 'Exporting...' : 'Export',
|
||||
onClick: onExportAudio,
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/30',
|
||||
disabled: !canExport || isExporting
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: 'Smart Speed',
|
||||
onClick: onToggleSmartSpeed,
|
||||
color: smartSpeedEnabled ? 'text-purple-500' : 'text-slate-400',
|
||||
bgColor: smartSpeedEnabled ? 'bg-purple-50 dark:bg-purple-900/30' : 'bg-slate-50 dark:bg-slate-800',
|
||||
active: smartSpeedEnabled
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{buttons.map((btn, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={btn.onClick}
|
||||
disabled={btn.disabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all whitespace-nowrap
|
||||
${btn.bgColor} ${btn.color}
|
||||
${btn.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md active:scale-95'}
|
||||
${btn.active ? 'ring-2 ring-purple-500 ring-offset-1' : ''}
|
||||
`}
|
||||
>
|
||||
<btn.icon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{btn.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
482
components/RSSManager.tsx
Normal file
482
components/RSSManager.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
155
components/StatsPanel.tsx
Normal file
155
components/StatsPanel.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { ReadingStats, VoiceName } from '../types';
|
||||
import { BarChart3, Clock, BookOpen, Flame, Trophy, Mic } from 'lucide-react';
|
||||
import { AVAILABLE_VOICES } from '../constants';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: ReadingStats;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const StatsPanel: React.FC<StatsPanelProps> = ({ stats, onClose }) => {
|
||||
const formatTime = (minutes: number): string => {
|
||||
if (minutes < 60) return `${Math.round(minutes)}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = Math.round(minutes % 60);
|
||||
return `${hours}h ${mins}m`;
|
||||
};
|
||||
|
||||
const favoriteVoiceData = AVAILABLE_VOICES.find(v => v.name === stats.favoriteVoice);
|
||||
|
||||
// Get last 7 days activity
|
||||
const last7Days = Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (6 - i));
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return {
|
||||
day: date.toLocaleDateString('en', { weekday: 'short' }),
|
||||
count: stats.articlesPerDay[dateStr] || 0
|
||||
};
|
||||
});
|
||||
|
||||
const maxCount = Math.max(...last7Days.map(d => d.count), 1);
|
||||
|
||||
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-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-6 h-6 text-blue-500" />
|
||||
Reading Stats
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 mb-1">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Articles Read</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{stats.totalArticlesRead}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 dark:bg-purple-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-purple-600 dark:text-purple-400 mb-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Time Listened</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{formatTime(stats.totalMinutesListened)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 dark:bg-orange-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-orange-600 dark:text-orange-400 mb-1">
|
||||
<Flame className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Current Streak</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{stats.currentStreak} <span className="text-sm font-normal">days</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 dark:bg-green-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 mb-1">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span className="text-xs font-medium">Best Streak</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{stats.longestStreak} <span className="text-sm font-normal">days</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Words Read */}
|
||||
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-xl p-4">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Total Words Consumed</p>
|
||||
<p className="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{stats.totalWordsRead.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
That's about {Math.round(stats.totalWordsRead / 250)} pages!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Weekly Activity */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
|
||||
Last 7 Days
|
||||
</h3>
|
||||
<div className="flex items-end justify-between gap-2 h-24">
|
||||
{last7Days.map((day, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-blue-500 dark:bg-blue-400 rounded-t transition-all"
|
||||
style={{
|
||||
height: `${Math.max((day.count / maxCount) * 60, 4)}px`,
|
||||
opacity: day.count > 0 ? 1 : 0.2
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{day.day}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Favorite Voice */}
|
||||
{favoriteVoiceData && (
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-900/30 dark:to-purple-900/30 rounded-xl p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{favoriteVoiceData.emoji}</span>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||
<Mic className="w-3 h-3" /> Favorite Voice
|
||||
</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-white">
|
||||
{favoriteVoiceData.label}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Used {stats.voiceUsage[stats.favoriteVoice] || 0} times
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
134
components/SummaryCard.tsx
Normal file
134
components/SummaryCard.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Article, VoiceName } from '../types';
|
||||
import { Sparkles, Play, Pause, Loader2, Clock, Brain } from 'lucide-react';
|
||||
import { generateSpeechFromText } from '../services/geminiService';
|
||||
import { base64ToUint8Array, createWavBlob } from '../services/audioUtils';
|
||||
|
||||
interface SummaryCardProps {
|
||||
article: Article;
|
||||
selectedVoice: VoiceName;
|
||||
onPlayFull: () => void;
|
||||
}
|
||||
|
||||
export const SummaryCard: React.FC<SummaryCardProps> = ({
|
||||
article,
|
||||
selectedVoice,
|
||||
onPlayFull
|
||||
}) => {
|
||||
const [isPlayingSummary, setIsPlayingSummary] = useState(false);
|
||||
const [isLoadingAudio, setIsLoadingAudio] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const handlePlaySummary = async () => {
|
||||
if (!article.summary) return;
|
||||
|
||||
if (isPlayingSummary && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setIsPlayingSummary(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already have the audio URL cached
|
||||
if (article.summaryAudioUrl && audioRef.current) {
|
||||
audioRef.current.src = article.summaryAudioUrl;
|
||||
await audioRef.current.play();
|
||||
setIsPlayingSummary(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate audio
|
||||
setIsLoadingAudio(true);
|
||||
try {
|
||||
const base64Audio = await generateSpeechFromText(article.summary, selectedVoice);
|
||||
const pcmData = base64ToUint8Array(base64Audio);
|
||||
const wavBlob = createWavBlob(pcmData);
|
||||
const audioUrl = URL.createObjectURL(wavBlob);
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
}
|
||||
|
||||
audioRef.current.src = audioUrl;
|
||||
audioRef.current.onended = () => setIsPlayingSummary(false);
|
||||
await audioRef.current.play();
|
||||
setIsPlayingSummary(true);
|
||||
} catch (e) {
|
||||
console.error('Failed to play summary:', e);
|
||||
} finally {
|
||||
setIsLoadingAudio(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!article.summary && !article.isSummaryLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const complexityColors = {
|
||||
simple: 'text-green-500 bg-green-50 dark:bg-green-900/30',
|
||||
moderate: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-900/30',
|
||||
complex: 'text-red-500 bg-red-50 dark:bg-red-900/30'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-indigo-900/20 dark:to-purple-900/20 rounded-2xl p-4 border border-indigo-100 dark:border-indigo-800/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-indigo-500" />
|
||||
<span className="text-sm font-semibold text-indigo-700 dark:text-indigo-300">
|
||||
30-Second Summary
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{article.complexity && (
|
||||
<span className={`text-xs px-2 py-1 rounded-full flex items-center gap-1 ${complexityColors[article.complexity]}`}>
|
||||
<Brain className="w-3 h-3" />
|
||||
{article.complexity}
|
||||
</span>
|
||||
)}
|
||||
{article.estimatedReadTime && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{article.estimatedReadTime}m read
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{article.isSummaryLoading ? (
|
||||
<div className="flex items-center gap-2 text-slate-500 dark:text-slate-400 py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">Generating summary...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||
{article.summary}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
onClick={handlePlaySummary}
|
||||
disabled={isLoadingAudio}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-xl text-sm font-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoadingAudio ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isPlayingSummary ? (
|
||||
<Pause className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
{isPlayingSummary ? 'Pause' : 'Listen to Summary'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onPlayFull}
|
||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
>
|
||||
Play full article →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
components/VoiceMoodSelector.tsx
Normal file
45
components/VoiceMoodSelector.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { VoiceMood, VoiceName } from '../types';
|
||||
import { VOICE_MOODS } from '../constants';
|
||||
|
||||
interface VoiceMoodSelectorProps {
|
||||
selectedMood: VoiceMood;
|
||||
onMoodChange: (mood: VoiceMood, voice: VoiceName, speed: number) => void;
|
||||
}
|
||||
|
||||
export const VoiceMoodSelector: React.FC<VoiceMoodSelectorProps> = ({
|
||||
selectedMood,
|
||||
onMoodChange
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 px-1">
|
||||
Listening Mood
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VOICE_MOODS.map((mood) => {
|
||||
const isSelected = selectedMood === mood.id;
|
||||
return (
|
||||
<button
|
||||
key={mood.id}
|
||||
onClick={() => onMoodChange(mood.id, mood.recommendedVoice, mood.recommendedSpeed)}
|
||||
className={`
|
||||
px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-2
|
||||
${isSelected
|
||||
? 'bg-blue-500 text-white shadow-md'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{mood.emoji}</span>
|
||||
<span>{mood.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{VOICE_MOODS.find(m => m.id === selectedMood)?.description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +1,27 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { VoiceName } from '../types';
|
||||
import { AVAILABLE_VOICES } from '../constants';
|
||||
import { Mic } from 'lucide-react';
|
||||
import { Play, Pause, Loader2, Check, Volume2 } from 'lucide-react';
|
||||
import { generateSpeechFromText } from '../services/geminiService';
|
||||
import { base64ToUint8Array, createWavBlob } from '../services/audioUtils';
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
selectedVoice: VoiceName;
|
||||
onVoiceChange: (voice: VoiceName) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VoiceSelector: React.FC<VoiceSelectorProps> = ({ selectedVoice, onVoiceChange, disabled }) => {
|
||||
// Compact selector for header
|
||||
export const VoiceSelectorCompact: React.FC<VoiceSelectorProps> = ({ selectedVoice, onVoiceChange }) => {
|
||||
const currentVoice = AVAILABLE_VOICES.find(v => v.name === selectedVoice);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mic className="w-4 h-4 text-slate-500 dark:text-slate-400" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{currentVoice?.emoji}</span>
|
||||
<select
|
||||
value={selectedVoice}
|
||||
onChange={(e) => onVoiceChange(e.target.value as VoiceName)}
|
||||
disabled={disabled}
|
||||
className="bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 text-slate-700 dark:text-slate-200 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300"
|
||||
className="bg-transparent border-none text-sm font-medium text-slate-700 dark:text-slate-200 cursor-pointer focus:outline-none"
|
||||
>
|
||||
{AVAILABLE_VOICES.map((v) => (
|
||||
<option key={v.name} value={v.name}>
|
||||
@@ -29,3 +32,153 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({ selectedVoice, onV
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Full voice panel with cards and preview
|
||||
export const VoicePanel: React.FC<VoiceSelectorProps> = ({ selectedVoice, onVoiceChange }) => {
|
||||
const [previewingVoice, setPreviewingVoice] = useState<VoiceName | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState<VoiceName | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const handlePreview = async (voice: typeof AVAILABLE_VOICES[0]) => {
|
||||
// If already previewing this voice, stop it
|
||||
if (previewingVoice === voice.name) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
setPreviewingVoice(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any current preview
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
|
||||
setLoadingPreview(voice.name);
|
||||
setPreviewingVoice(null);
|
||||
|
||||
try {
|
||||
const base64Audio = await generateSpeechFromText(voice.previewText, voice.name);
|
||||
const pcmData = base64ToUint8Array(base64Audio);
|
||||
const wavBlob = createWavBlob(pcmData);
|
||||
const audioUrl = URL.createObjectURL(wavBlob);
|
||||
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
}
|
||||
|
||||
audioRef.current.src = audioUrl;
|
||||
audioRef.current.onended = () => {
|
||||
setPreviewingVoice(null);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
audioRef.current.onerror = () => {
|
||||
setPreviewingVoice(null);
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
|
||||
await audioRef.current.play();
|
||||
setPreviewingVoice(voice.name);
|
||||
} catch (error) {
|
||||
console.error('Preview failed:', error);
|
||||
} finally {
|
||||
setLoadingPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (voiceName: VoiceName) => {
|
||||
onVoiceChange(voiceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Volume2 className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Choose Your Voice</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{AVAILABLE_VOICES.map((voice) => {
|
||||
const isSelected = selectedVoice === voice.name;
|
||||
const isPreviewing = previewingVoice === voice.name;
|
||||
const isLoading = loadingPreview === voice.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={voice.name}
|
||||
className={`
|
||||
relative p-4 rounded-2xl border-2 transition-all cursor-pointer
|
||||
${isSelected
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-md'
|
||||
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 hover:border-slate-300 dark:hover:border-slate-600 hover:shadow-sm'
|
||||
}
|
||||
`}
|
||||
onClick={() => handleSelect(voice.name)}
|
||||
>
|
||||
{/* Selected checkmark */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice info */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="text-2xl">{voice.emoji}</div>
|
||||
<div>
|
||||
<p className={`font-semibold ${isSelected ? 'text-blue-700 dark:text-blue-300' : 'text-slate-800 dark:text-slate-200'}`}>
|
||||
{voice.label}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
{voice.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview(voice);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full mt-2 py-2 px-3 rounded-xl text-xs font-medium transition-all flex items-center justify-center gap-2
|
||||
${isPreviewing
|
||||
? 'bg-blue-500 text-white'
|
||||
: isSelected
|
||||
? 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-800/50 dark:text-blue-200 dark:hover:bg-blue-800'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700'
|
||||
}
|
||||
${isLoading ? 'opacity-70 cursor-wait' : ''}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : isPreviewing ? (
|
||||
<>
|
||||
<Pause className="w-3 h-3" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-3 h-3" />
|
||||
Preview
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Keep backward compatibility
|
||||
export const VoiceSelector = VoiceSelectorCompact;
|
||||
|
||||
Reference in New Issue
Block a user