Compare commits
13 Commits
7f75b44af1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173c02b952 | ||
|
|
514bab1771 | ||
|
|
9025d1b8f1 | ||
|
|
3169438f7e | ||
|
|
b310c090ce | ||
|
|
fb4f8fa795 | ||
|
|
bd029ad9f1 | ||
|
|
7d1826e44f | ||
|
|
1cdcc08eae | ||
|
|
c610fcf88b | ||
|
|
a48eba5613 | ||
|
|
061474c574 | ||
|
|
8e1dff0bb3 |
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Copy this file to .env.local and set your Gemini API key
|
||||
VITE_API_KEY=your_key_here
|
||||
17
.gitea/workflows/demo.yaml
Normal file
17
.gitea/workflows/demo.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Build News-reader-pro
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20' # Adjust to your Node.js version
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
- name: Build the project
|
||||
run: npm run build
|
||||
- name: List build output
|
||||
run: ls -la dist/
|
||||
539
App.tsx
539
App.tsx
@@ -1,41 +1,67 @@
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2 } from 'lucide-react';
|
||||
import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types';
|
||||
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
|
||||
import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
|
||||
import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2, History as HistoryIcon, Waves } from 'lucide-react';
|
||||
import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings, VoiceMood, ReadingStats, Bookmark } from './types';
|
||||
import { MIN_SPEED, MAX_SPEED, SPEED_STEP, SMART_SPEED_MULTIPLIERS } from './constants';
|
||||
import { extractArticleContent, generateSpeechFromText, generateArticleSummary, analyzeTextComplexity } from './services/geminiService';
|
||||
import { base64ToUint8Array, createWavBlob } from './services/audioUtils';
|
||||
import { createTrackedObjectUrl, revokeAllTrackedObjectUrls, revokeMultipleObjectUrls, revokeTrackedObjectUrl } from './services/objectUrlManager';
|
||||
import { segmentText } from './services/textUtils';
|
||||
import { getStats, saveBookmark, getBookmarkForUrl, updateStatsOnArticleComplete } from './services/storageService';
|
||||
import { exportAndDownloadArticle } from './services/audioExportService';
|
||||
import { QueueItem } from './components/QueueItem';
|
||||
import { VoiceSelector } from './components/VoiceSelector';
|
||||
import { VoiceSelector, VoicePanel } from './components/VoiceSelector';
|
||||
import { ReaderView } from './components/ReaderView';
|
||||
import { StatsPanel } from './components/StatsPanel';
|
||||
import { RSSManager } from './components/RSSManager';
|
||||
import { BookmarksPanel } from './components/BookmarksPanel';
|
||||
import { VoiceMoodSelector } from './components/VoiceMoodSelector';
|
||||
import { FeatureToolbar } from './components/FeatureToolbar';
|
||||
import { SummaryCard } from './components/SummaryCard';
|
||||
|
||||
export default function App() {
|
||||
// -- State --
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [queue, setQueue] = useState<Article[]>([]);
|
||||
const [history, setHistory] = useState<Article[]>([]);
|
||||
const [viewId, setViewId] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [activeList, setActiveList] = useState<'queue' | 'history'>('queue');
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
|
||||
const [playerState, setPlayerState] = useState<PlayerState>({
|
||||
isPlaying: false,
|
||||
playbackRate: 1.0,
|
||||
currentArticleId: null,
|
||||
selectedVoice: VoiceName.Puck,
|
||||
voiceMood: 'neutral',
|
||||
smartSpeedEnabled: false,
|
||||
});
|
||||
|
||||
// New feature states
|
||||
const [showStatsPanel, setShowStatsPanel] = useState(false);
|
||||
const [showRSSManager, setShowRSSManager] = useState(false);
|
||||
const [showBookmarksPanel, setShowBookmarksPanel] = useState(false);
|
||||
const [readingStats, setReadingStats] = useState<ReadingStats>(getStats());
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<ReaderSettings>({
|
||||
isDarkMode: false,
|
||||
fontSize: 'lg',
|
||||
lineHeight: 'relaxed',
|
||||
fontFamily: 'serif',
|
||||
autoScroll: true
|
||||
autoScroll: true,
|
||||
readingTone: 'clean',
|
||||
pageWidth: 'standard',
|
||||
zenMode: false
|
||||
});
|
||||
|
||||
|
||||
// -- Refs --
|
||||
const audioRef = useRef<HTMLAudioElement>(new Audio());
|
||||
const processingRef = useRef<Set<string>>(new Set());
|
||||
const voiceGenerationRef = useRef<number>(0); // Track voice changes to invalidate old requests
|
||||
|
||||
// -- Helpers --
|
||||
const getCurrentArticle = () => queue.find(a => a.id === playerState.currentArticleId);
|
||||
@@ -47,6 +73,11 @@ export default function App() {
|
||||
return null;
|
||||
};
|
||||
|
||||
const cleanupArticleAudio = (article?: Article) => {
|
||||
if (!article) return;
|
||||
revokeMultipleObjectUrls(article.segments.map(seg => seg.audioUrl));
|
||||
};
|
||||
|
||||
// -- State Updaters --
|
||||
|
||||
const updateArticle = (id: string, updates: Partial<Article>) => {
|
||||
@@ -57,7 +88,12 @@ export default function App() {
|
||||
setQueue(prev => prev.map(article => {
|
||||
if (article.id !== articleId) return article;
|
||||
const newSegments = article.segments.map(seg =>
|
||||
seg.id === segmentId ? { ...seg, ...updates } : seg
|
||||
seg.id === segmentId ? (() => {
|
||||
if ('audioUrl' in updates && seg.audioUrl && updates.audioUrl !== seg.audioUrl) {
|
||||
revokeTrackedObjectUrl(seg.audioUrl);
|
||||
}
|
||||
return { ...seg, ...updates };
|
||||
})() : seg
|
||||
);
|
||||
return { ...article, segments: newSegments };
|
||||
}));
|
||||
@@ -69,14 +105,24 @@ export default function App() {
|
||||
const uniqueKey = `${articleId}-${segmentId}`;
|
||||
if (processingRef.current.has(uniqueKey)) return;
|
||||
|
||||
// Capture the current generation - if voice changes, this will be outdated
|
||||
const generation = voiceGenerationRef.current;
|
||||
|
||||
processingRef.current.add(uniqueKey);
|
||||
updateSegment(articleId, segmentId, { isLoading: true });
|
||||
|
||||
try {
|
||||
const base64Audio = await generateSpeechFromText(text, voice);
|
||||
|
||||
// Check if voice generation changed while we were generating
|
||||
if (generation !== voiceGenerationRef.current) {
|
||||
console.log(`Discarding segment ${segmentId} - voice changed during generation`);
|
||||
return; // Discard this result, voice has changed
|
||||
}
|
||||
|
||||
const pcmData = base64ToUint8Array(base64Audio);
|
||||
const wavBlob = createWavBlob(pcmData);
|
||||
const audioUrl = URL.createObjectURL(wavBlob);
|
||||
const audioUrl = createTrackedObjectUrl(wavBlob);
|
||||
updateSegment(articleId, segmentId, { audioUrl, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Segment generation failed", error);
|
||||
@@ -102,54 +148,128 @@ export default function App() {
|
||||
// -- Handlers --
|
||||
|
||||
const handleVoiceChange = useCallback((newVoice: VoiceName) => {
|
||||
// Increment generation counter to invalidate all in-flight requests
|
||||
voiceGenerationRef.current += 1;
|
||||
|
||||
setPlayerState(prev => ({ ...prev, selectedVoice: newVoice }));
|
||||
|
||||
// Force flush future buffer so new voice is applied immediately
|
||||
// Force flush ALL audio buffers so new voice is applied immediately
|
||||
setQueue(prevQueue => prevQueue.map(article => {
|
||||
// If this is the currently active article
|
||||
if (article.id === playerState.currentArticleId) {
|
||||
return {
|
||||
...article,
|
||||
segments: article.segments.map((seg, idx) => {
|
||||
// Keep the current segment (and past ones) to avoid cutting off mid-speech abruptly
|
||||
if (idx <= article.currentSegmentIndex) {
|
||||
return seg;
|
||||
}
|
||||
// Invalidate all future segments
|
||||
return { ...seg, audioUrl: undefined, isLoading: false, hasError: false };
|
||||
})
|
||||
};
|
||||
}
|
||||
// For inactive articles, invalidate everything
|
||||
return {
|
||||
...article,
|
||||
segments: article.segments.map(seg => ({
|
||||
...seg,
|
||||
audioUrl: undefined,
|
||||
isLoading: false,
|
||||
hasError: false
|
||||
}))
|
||||
segments: article.segments.map(seg => {
|
||||
if (seg.audioUrl) {
|
||||
revokeTrackedObjectUrl(seg.audioUrl);
|
||||
}
|
||||
// Invalidate ALL segments to force regeneration with new voice
|
||||
return { ...seg, audioUrl: undefined, isLoading: false, hasError: false };
|
||||
})
|
||||
};
|
||||
}));
|
||||
}, [playerState.currentArticleId]);
|
||||
}, []);
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
if (!inputUrl.trim()) return;
|
||||
// -- New Feature Handlers --
|
||||
|
||||
const handleMoodChange = useCallback((mood: VoiceMood, voice: VoiceName, speed: number) => {
|
||||
setPlayerState(prev => ({
|
||||
...prev,
|
||||
voiceMood: mood,
|
||||
selectedVoice: voice,
|
||||
playbackRate: speed
|
||||
}));
|
||||
// Also trigger voice change to regenerate audio
|
||||
handleVoiceChange(voice);
|
||||
}, [handleVoiceChange]);
|
||||
|
||||
const handleSmartSpeedToggle = useCallback(() => {
|
||||
setPlayerState(prev => ({
|
||||
...prev,
|
||||
smartSpeedEnabled: !prev.smartSpeedEnabled
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleExportAudio = useCallback(async () => {
|
||||
const article = getCurrentArticle();
|
||||
if (!article) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await exportAndDownloadArticle(article);
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResumeFromBookmark = useCallback(async (url: string, segmentIndex: number) => {
|
||||
// First enqueue the article
|
||||
await enqueueArticle(url, { autoPlay: false, pinView: true });
|
||||
|
||||
// Then set the segment index once loaded
|
||||
setTimeout(() => {
|
||||
setQueue(prev => {
|
||||
const article = prev.find(a => a.url === url);
|
||||
if (article) {
|
||||
return prev.map(a =>
|
||||
a.url === url ? { ...a, currentSegmentIndex: segmentIndex } : a
|
||||
);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const handleRSSArticleSelect = useCallback((url: string) => {
|
||||
enqueueArticle(url, { autoPlay: true, pinView: true });
|
||||
}, []);
|
||||
|
||||
// Save bookmark when pausing
|
||||
const saveCurrentBookmark = useCallback(() => {
|
||||
const article = getCurrentArticle();
|
||||
if (!article || article.segments.length === 0) return;
|
||||
|
||||
const progress = Math.round(
|
||||
((article.currentSegmentIndex + 1) / article.segments.length) * 100
|
||||
);
|
||||
|
||||
const bookmark: Bookmark = {
|
||||
articleId: article.id,
|
||||
url: article.url,
|
||||
title: article.title,
|
||||
segmentIndex: article.currentSegmentIndex,
|
||||
savedAt: Date.now(),
|
||||
progress
|
||||
};
|
||||
|
||||
saveBookmark(bookmark);
|
||||
}, []);
|
||||
|
||||
const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean; resumeSegment?: number } = {}) => {
|
||||
const normalizedUrl = targetUrl.trim();
|
||||
if (!normalizedUrl) return;
|
||||
|
||||
const existing = queue.find(item => item.url === normalizedUrl);
|
||||
if (existing) {
|
||||
setViewId(existing.id);
|
||||
if (options.autoPlay) playArticle(existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const newArticle: Article = {
|
||||
id,
|
||||
url: inputUrl,
|
||||
url: normalizedUrl,
|
||||
title: 'Fetching info...',
|
||||
text: '',
|
||||
segments: [],
|
||||
currentSegmentIndex: 0,
|
||||
status: PlaybackStatus.LOADING_TEXT
|
||||
status: PlaybackStatus.LOADING_TEXT,
|
||||
addedAt: Date.now()
|
||||
};
|
||||
|
||||
setQueue(prev => [...prev, newArticle]);
|
||||
setInputUrl('');
|
||||
if (!playerState.isPlaying) setViewId(id);
|
||||
if (!playerState.isPlaying || options.pinView) setViewId(id);
|
||||
|
||||
try {
|
||||
const { title, text } = await extractArticleContent(newArticle.url);
|
||||
@@ -160,11 +280,26 @@ export default function App() {
|
||||
if (titleSegment) segments.unshift(titleSegment);
|
||||
}
|
||||
|
||||
// Analyze complexity for Smart Speed
|
||||
const { complexity, wordCount, estimatedReadTime } = analyzeTextComplexity(text);
|
||||
|
||||
updateArticle(id, {
|
||||
title,
|
||||
text,
|
||||
segments,
|
||||
status: PlaybackStatus.LOADING_AUDIO
|
||||
status: PlaybackStatus.LOADING_AUDIO,
|
||||
complexity,
|
||||
wordCount,
|
||||
estimatedReadTime,
|
||||
isSummaryLoading: true
|
||||
});
|
||||
|
||||
// Generate summary in background
|
||||
generateArticleSummary(text, title).then(summary => {
|
||||
updateArticle(id, { summary, isSummaryLoading: false });
|
||||
}).catch(e => {
|
||||
console.warn('Summary generation failed:', e);
|
||||
updateArticle(id, { isSummaryLoading: false });
|
||||
});
|
||||
|
||||
if (segments.length > 0) {
|
||||
@@ -176,7 +311,7 @@ export default function App() {
|
||||
|
||||
setQueue(prev => {
|
||||
const current = prev.find(a => a.id === id);
|
||||
if (current && !playerState.isPlaying) {
|
||||
if (current && (!playerState.isPlaying || options.autoPlay)) {
|
||||
playArticle(id);
|
||||
}
|
||||
return prev;
|
||||
@@ -193,6 +328,12 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
await enqueueArticle(inputUrl, { autoPlay: !playerState.isPlaying, pinView: true });
|
||||
setInputUrl('');
|
||||
};
|
||||
|
||||
|
||||
// -- Playback Control --
|
||||
|
||||
const playArticle = useCallback(async (id: string) => {
|
||||
@@ -206,8 +347,10 @@ export default function App() {
|
||||
setPlayerState(prev => ({ ...prev, isPlaying: false }));
|
||||
if (playerState.currentArticleId) {
|
||||
updateArticle(playerState.currentArticleId, { status: PlaybackStatus.PAUSED });
|
||||
// Save bookmark on pause
|
||||
saveCurrentBookmark();
|
||||
}
|
||||
}, [playerState.currentArticleId]);
|
||||
}, [playerState.currentArticleId, saveCurrentBookmark]);
|
||||
|
||||
const skipSegment = useCallback((direction: 'next' | 'prev') => {
|
||||
setQueue(prevQueue => {
|
||||
@@ -233,6 +376,34 @@ export default function App() {
|
||||
});
|
||||
}, [playerState.currentArticleId]);
|
||||
|
||||
const handleReorder = useCallback((sourceId: string, targetId: string) => {
|
||||
if (sourceId === targetId) return;
|
||||
setQueue(prev => {
|
||||
const sourceIndex = prev.findIndex(a => a.id === sourceId);
|
||||
const targetIndex = prev.findIndex(a => a.id === targetId);
|
||||
if (sourceIndex === -1 || targetIndex === -1) return prev;
|
||||
const updated = [...prev];
|
||||
const [moved] = updated.splice(sourceIndex, 1);
|
||||
updated.splice(targetIndex, 0, moved);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePlayNext = useCallback((articleId: string) => {
|
||||
setQueue(prev => {
|
||||
const sourceIndex = prev.findIndex(a => a.id === articleId);
|
||||
if (sourceIndex === -1) return prev;
|
||||
const updated = [...prev];
|
||||
const [picked] = updated.splice(sourceIndex, 1);
|
||||
const currentId = playerState.currentArticleId;
|
||||
const currentIndex = currentId ? updated.findIndex(a => a.id === currentId) : -1;
|
||||
const insertIndex = currentIndex === -1 ? 0 : currentIndex + 1;
|
||||
updated.splice(insertIndex, 0, picked);
|
||||
return updated;
|
||||
});
|
||||
setActiveList('queue');
|
||||
}, [playerState.currentArticleId]);
|
||||
|
||||
const handleSegmentSelect = useCallback((articleId: string, index: number) => {
|
||||
setPlayerState(prev => ({
|
||||
...prev,
|
||||
@@ -375,8 +546,27 @@ export default function App() {
|
||||
return () => audio.removeEventListener('ended', handleEnded);
|
||||
}, [playerState.currentArticleId, playArticle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
revokeAllTrackedObjectUrls();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHistory(prev => {
|
||||
const seen = new Set(prev.map(h => h.id));
|
||||
const completed = queue.filter(item => item.status === PlaybackStatus.COMPLETED && !seen.has(item.id));
|
||||
if (completed.length === 0) return prev;
|
||||
return [...prev, ...completed];
|
||||
});
|
||||
}, [queue]);
|
||||
|
||||
const handleSpeedChange = (newSpeed: number) => {
|
||||
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
|
||||
// Round to nearest 0.5 to keep clean values
|
||||
const rounded = Math.round(newSpeed * 2) / 2;
|
||||
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, rounded));
|
||||
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = speed;
|
||||
@@ -404,19 +594,12 @@ export default function App() {
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-white tracking-tight hidden sm:block">NewsCaster AI</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<VoiceSelector
|
||||
selectedVoice={playerState.selectedVoice}
|
||||
onVoiceChange={handleVoiceChange}
|
||||
// Removed disabled prop to allow switching while playing
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings Menu */}
|
||||
@@ -486,6 +669,60 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ambient Tone */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-slate-700 dark:text-slate-300 text-sm font-medium block">Ambient mode</span>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{[
|
||||
{ key: 'clean', label: 'Clean' },
|
||||
{ key: 'sepia', label: 'Sepia' },
|
||||
{ key: 'night', label: 'Night Light' }
|
||||
].map(option => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => setSettings(s => ({...s, readingTone: option.key as any}))}
|
||||
className={`px-3 py-2 rounded-lg border transition-all ${settings.readingTone === option.key ? 'border-blue-500 text-blue-600 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30' : 'border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Width */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-700 dark:text-slate-300 font-medium">Page width</span>
|
||||
<span className="text-xs text-slate-400">{settings.pageWidth === 'cozy' ? 'Cozy' : settings.pageWidth === 'wide' ? 'Wide' : 'Comfort'}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="1"
|
||||
value={['cozy','standard','wide'].indexOf(settings.pageWidth)}
|
||||
onChange={(e) => {
|
||||
const options = ['cozy','standard','wide'] as const;
|
||||
setSettings(s => ({...s, pageWidth: options[parseInt(e.target.value)]}));
|
||||
}}
|
||||
className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zen Mode */}
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Zen mode</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Hide chrome for distraction-free reading.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings(s => ({...s, zenMode: !s.zenMode}))}
|
||||
className={`px-3 py-1 rounded-full text-xs font-semibold transition-all ${settings.zenMode ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200' : 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300'}`}
|
||||
>
|
||||
{settings.zenMode ? 'On' : 'Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts Hint */}
|
||||
<div className="pt-4 border-t border-slate-100 dark:border-slate-800">
|
||||
<p className="text-xs text-slate-400 flex items-center gap-2">
|
||||
@@ -504,59 +741,145 @@ export default function App() {
|
||||
{/* Left Column: Controls & Queue */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
{/* Input */}
|
||||
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 flex gap-2 items-center pl-4 transition-colors duration-300">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Paste article URL here..."
|
||||
className="flex-grow py-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUrl}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Queue</span>
|
||||
</button>
|
||||
<div className="bg-white dark:bg-slate-900 p-1 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 transition-colors duration-300 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Paste article URL here..."
|
||||
className="flex-grow py-3 px-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0 rounded-xl bg-slate-50 dark:bg-slate-800"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUrl}
|
||||
disabled={!inputUrl.trim()}
|
||||
className="bg-slate-900 hover:bg-slate-800 dark:bg-blue-600 dark:hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-4 sm:px-6 py-3 rounded-xl font-medium transition-all flex items-center gap-2 flex-shrink-0 shadow-lg self-end sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Queue</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 px-3 pb-2">Paste any article URL to start listening.</p>
|
||||
</div>
|
||||
|
||||
{/* Voice Mood Selector */}
|
||||
<VoiceMoodSelector
|
||||
selectedMood={playerState.voiceMood}
|
||||
onMoodChange={handleMoodChange}
|
||||
/>
|
||||
|
||||
{/* Voice Panel */}
|
||||
<VoicePanel
|
||||
selectedVoice={playerState.selectedVoice}
|
||||
onVoiceChange={handleVoiceChange}
|
||||
/>
|
||||
|
||||
{/* Feature Toolbar */}
|
||||
<FeatureToolbar
|
||||
onOpenStats={() => setShowStatsPanel(true)}
|
||||
onOpenRSS={() => setShowRSSManager(true)}
|
||||
onOpenBookmarks={() => setShowBookmarksPanel(true)}
|
||||
onExportAudio={handleExportAudio}
|
||||
smartSpeedEnabled={playerState.smartSpeedEnabled}
|
||||
onToggleSmartSpeed={handleSmartSpeedToggle}
|
||||
canExport={!!getCurrentArticle()?.segments.some(s => s.audioUrl)}
|
||||
isExporting={isExporting}
|
||||
/>
|
||||
|
||||
{/* Summary Card for viewing article */}
|
||||
{viewingArticle && (viewingArticle.summary || viewingArticle.isSummaryLoading) && (
|
||||
<SummaryCard
|
||||
article={viewingArticle}
|
||||
selectedVoice={playerState.selectedVoice}
|
||||
onPlayFull={() => viewingArticle && playArticle(viewingArticle.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Queue List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Up Next</h2>
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">{queue.length} articles</span>
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-full p-1">
|
||||
{([['queue','Queue'], ['history','History']] as const).map(([key,label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveList(key)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-all ${activeList === key ? 'bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-blue-300' : 'text-slate-500 dark:text-slate-400'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 px-2 py-1 rounded-full">
|
||||
{activeList === 'queue' ? `${queue.length} queued` : `${history.length} played`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{queue.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||
<p>No articles queued.</p>
|
||||
</div>
|
||||
{activeList === 'queue' ? (
|
||||
<div className="space-y-3">
|
||||
{queue.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||
<p>No articles queued.</p>
|
||||
<p className="text-xs mt-1">Tap a vibe above to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
queue.map(article => (
|
||||
<div
|
||||
key={article.id}
|
||||
onClick={() => setViewId(article.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<QueueItem
|
||||
article={article}
|
||||
isActive={article.id === playerState.currentArticleId}
|
||||
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
|
||||
onPlay={() => playArticle(article.id)}
|
||||
onPause={pausePlayback}
|
||||
onPlayNext={() => handlePlayNext(article.id)}
|
||||
draggable
|
||||
onDragStart={() => setDraggingId(article.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); if (draggingId) handleReorder(draggingId, article.id); setDraggingId(null); }}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
onRemove={() => {
|
||||
if (playerState.currentArticleId === article.id) {
|
||||
pausePlayback();
|
||||
setPlayerState(prev => ({ ...prev, currentArticleId: null, isPlaying: false }));
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
setQueue(prev => {
|
||||
const target = prev.find(a => a.id === article.id);
|
||||
cleanupArticleAudio(target);
|
||||
return prev.filter(a => a.id !== article.id);
|
||||
});
|
||||
setViewId(prev => prev === article.id ? null : prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-10 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl text-slate-400 dark:text-slate-600 bg-white dark:bg-slate-900/50">
|
||||
<p>No history yet.</p>
|
||||
<p className="text-xs mt-1">Finished listens will show up here automatically.</p>
|
||||
</div>
|
||||
) : (
|
||||
queue.map(article => (
|
||||
<div key={article.id} onClick={() => setViewId(article.id)} className="cursor-pointer">
|
||||
<QueueItem
|
||||
article={article}
|
||||
isActive={article.id === playerState.currentArticleId}
|
||||
isPlaying={playerState.isPlaying && article.id === playerState.currentArticleId}
|
||||
onPlay={() => playArticle(article.id)}
|
||||
onPause={pausePlayback}
|
||||
onRemove={() => {
|
||||
if (playerState.currentArticleId === article.id) {
|
||||
pausePlayback();
|
||||
setPlayerState(prev => ({ ...prev, currentArticleId: null }));
|
||||
}
|
||||
setQueue(prev => prev.filter(a => a.id !== article.id));
|
||||
if (viewId === article.id) setViewId(null);
|
||||
}}
|
||||
/>
|
||||
history.map(item => (
|
||||
<div key={item.id} className="p-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">
|
||||
<HistoryIcon className="w-4 h-4 text-slate-400" />
|
||||
<span className="truncate">{item.title || item.url}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{item.url}</p>
|
||||
<div className="text-[11px] text-green-600 dark:text-green-400 mt-1">Completed</div>
|
||||
</div>
|
||||
))
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -666,6 +989,28 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Panels */}
|
||||
{showStatsPanel && (
|
||||
<StatsPanel
|
||||
stats={readingStats}
|
||||
onClose={() => setShowStatsPanel(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRSSManager && (
|
||||
<RSSManager
|
||||
onArticleSelect={handleRSSArticleSelect}
|
||||
onClose={() => setShowRSSManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBookmarksPanel && (
|
||||
<BookmarksPanel
|
||||
onResumeArticle={handleResumeFromBookmark}
|
||||
onClose={() => setShowBookmarksPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,6 @@ View your app in AI Studio: https://ai.studio/apps/drive/1a8wkyYOUvPDWvUXbrtN2dz
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
2. Copy `.env.example` to `.env.local` and set `VITE_API_KEY` to your Gemini API key (e.g., `VITE_API_KEY=your_key_here`).
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Article, PlaybackStatus } from '../types';
|
||||
import { Play, Pause, Loader2, AlertCircle, FileText } from 'lucide-react';
|
||||
import { Play, Pause, Loader2, AlertCircle, FileText, GripVertical, SkipForward } from 'lucide-react';
|
||||
|
||||
interface QueueItemProps {
|
||||
article: Article;
|
||||
@@ -10,6 +10,12 @@ interface QueueItemProps {
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onRemove: () => void;
|
||||
onPlayNext?: () => void;
|
||||
draggable?: boolean;
|
||||
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDragOver?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export const QueueItem: React.FC<QueueItemProps> = ({
|
||||
@@ -18,7 +24,13 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
||||
isPlaying,
|
||||
onPlay,
|
||||
onPause,
|
||||
onRemove
|
||||
onRemove,
|
||||
onPlayNext,
|
||||
draggable,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd
|
||||
}) => {
|
||||
|
||||
// Check if buffering: active, supposed to be playing, but current segment audio is missing
|
||||
@@ -52,13 +64,24 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
||||
const isReady = article.status === PlaybackStatus.READY || article.status === PlaybackStatus.PAUSED || article.status === PlaybackStatus.PLAYING || article.status === PlaybackStatus.COMPLETED;
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`
|
||||
relative group flex items-center p-4 rounded-xl border transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 shadow-sm'
|
||||
: 'bg-white border-slate-100 hover:border-slate-300 dark:bg-slate-800 dark:border-slate-700 dark:hover:border-slate-600'
|
||||
}
|
||||
`}>
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0 mr-2 w-6 flex justify-center text-slate-300 dark:text-slate-600 cursor-grab">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 mr-4 w-8 flex justify-center">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
@@ -84,6 +107,15 @@ export const QueueItem: React.FC<QueueItemProps> = ({
|
||||
{isActive && isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{onPlayNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPlayNext(); }}
|
||||
className="p-2 rounded-full bg-slate-50 hover:bg-amber-50 dark:bg-slate-700 dark:hover:bg-amber-900/40 text-amber-600 dark:text-amber-300 transition-colors"
|
||||
title="Play this article next"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent article selection when removing
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Article, ReaderSettings } from '../types';
|
||||
import { FileText, MousePointerClick } from 'lucide-react';
|
||||
import { FileText, MousePointerClick, Share2, Quote } from 'lucide-react';
|
||||
import { getDisplayUrl } from '../utils/url';
|
||||
|
||||
interface ReaderViewProps {
|
||||
article?: Article | null;
|
||||
@@ -29,7 +30,10 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
||||
fontSize: 'lg',
|
||||
lineHeight: 'relaxed',
|
||||
fontFamily: 'serif',
|
||||
autoScroll: true
|
||||
autoScroll: true,
|
||||
readingTone: 'clean',
|
||||
pageWidth: 'standard',
|
||||
zenMode: false
|
||||
};
|
||||
|
||||
const getFontClass = () => {
|
||||
@@ -58,6 +62,37 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
||||
}
|
||||
};
|
||||
|
||||
const getToneClasses = () => {
|
||||
switch (s.readingTone) {
|
||||
case 'sepia':
|
||||
return 'bg-amber-50/60 dark:bg-amber-900/30 border border-amber-100 dark:border-amber-800';
|
||||
case 'night':
|
||||
return 'bg-slate-900 text-slate-100 border border-slate-800';
|
||||
default:
|
||||
return 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getWidthClass = () => {
|
||||
switch (s.pageWidth) {
|
||||
case 'cozy':
|
||||
return 'max-w-2xl mx-auto';
|
||||
case 'wide':
|
||||
return 'max-w-6xl mx-auto';
|
||||
default:
|
||||
return 'max-w-4xl mx-auto';
|
||||
}
|
||||
};
|
||||
|
||||
const handleShareMoment = (text: string, index: number) => {
|
||||
const base = typeof window !== 'undefined' ? `${window.location.origin}${window.location.pathname}` : '';
|
||||
const shareUrl = `${base}?segment=${index}`;
|
||||
const payload = `"${text.trim()}"\n${shareUrl}`;
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(payload).catch(() => console.warn('Unable to copy share link'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-400 dark:text-slate-600 p-12 border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl bg-slate-50/50 dark:bg-slate-900/50 transition-colors duration-300">
|
||||
@@ -68,40 +103,44 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-sm overflow-hidden h-[calc(100vh-12rem)] flex flex-col transition-colors duration-300">
|
||||
<div className="p-6 border-b border-slate-100 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-10 flex justify-between items-start">
|
||||
<div className="flex-1 pr-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
|
||||
{article.title}
|
||||
</h2>
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block"
|
||||
>
|
||||
{new URL(article.url).hostname}
|
||||
</a>
|
||||
</div>
|
||||
const displayUrl = getDisplayUrl(article.url);
|
||||
|
||||
{/* Auto Scroll Toggle */}
|
||||
<button
|
||||
onClick={onToggleAutoScroll}
|
||||
title={s.autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
s.autoScroll
|
||||
? 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<MousePointerClick className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div className={`${getToneClasses()} ${getWidthClass()} rounded-2xl shadow-sm overflow-hidden h-[calc(100vh-12rem)] flex flex-col transition-colors duration-300`}>
|
||||
{!s.zenMode && (
|
||||
<div className="p-6 border-b border-slate-100 dark:border-slate-800 bg-transparent sticky top-0 z-10 flex justify-between items-start">
|
||||
<div className="flex-1 pr-4">
|
||||
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
|
||||
{article.title}
|
||||
</h2>
|
||||
<a
|
||||
href={displayUrl.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block"
|
||||
>
|
||||
{displayUrl.hostname}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Auto Scroll Toggle */}
|
||||
<button
|
||||
onClick={onToggleAutoScroll}
|
||||
title={s.autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
s.autoScroll
|
||||
? 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<MousePointerClick className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`flex-grow overflow-y-auto p-6 sm:p-8 space-y-1 custom-scrollbar bg-white dark:bg-slate-900 transition-colors duration-300 ${getFontClass()} ${getSizeClass()}`}
|
||||
className={`flex-grow overflow-y-auto p-6 sm:p-8 space-y-3 custom-scrollbar transition-colors duration-300 ${getFontClass()} ${getSizeClass()} ${getLeadingClass()} ${s.zenMode ? 'pt-10' : ''}`}
|
||||
>
|
||||
{article.segments.length > 0 ? (
|
||||
article.segments.map((segment, idx) => {
|
||||
@@ -116,14 +155,31 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
|
||||
title="Click to play from here"
|
||||
className={`
|
||||
transition-all duration-200 whitespace-pre-wrap rounded-xl p-3 sm:p-4 -mx-2 sm:-mx-4 border-l-4 mb-2
|
||||
${getLeadingClass()}
|
||||
${isActive
|
||||
? `bg-blue-50 dark:bg-blue-900/20 border-blue-500 shadow-sm ${isBuffering ? 'animate-pulse opacity-70' : 'text-slate-900 dark:text-white'}`
|
||||
: 'text-slate-700 dark:text-slate-300 border-transparent hover:bg-slate-100 dark:hover:bg-slate-800/50 cursor-pointer hover:border-slate-300 dark:hover:border-slate-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{segment.text}
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<p className="flex-1 whitespace-pre-wrap">{segment.text}</p>
|
||||
<div className="flex flex-col gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleShareMoment(segment.text, idx); }}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Copy a shareable moment"
|
||||
>
|
||||
<Share2 className="w-3 h-3" /> Share
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (navigator?.clipboard?.writeText) navigator.clipboard.writeText(segment.text); }}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-800 transition-colors"
|
||||
title="Copy this quote"
|
||||
>
|
||||
<Quote className="w-3 h-3" /> Clip
|
||||
</button>
|
||||
</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;
|
||||
|
||||
99
constants.ts
99
constants.ts
@@ -2,12 +2,48 @@
|
||||
import { VoiceName } from './types';
|
||||
|
||||
export const AVAILABLE_VOICES = [
|
||||
{ name: VoiceName.Puck, label: 'Puck (Standard American, Male)' },
|
||||
{ name: VoiceName.Charon, label: 'Charon (Deep, Authoritative Male)' },
|
||||
{ name: VoiceName.Kore, label: 'Kore (Soft, Calm Female)' },
|
||||
{ name: VoiceName.Fenrir, label: 'Fenrir (Mid-Atlantic/British Style, Male)' },
|
||||
{ name: VoiceName.Zephyr, label: 'Zephyr (Clear, Professional Female)' },
|
||||
{ name: VoiceName.Aoede, label: 'Aoede (Confident, Professional Female)' },
|
||||
{
|
||||
name: VoiceName.Puck,
|
||||
label: 'Puck',
|
||||
description: 'Standard American, Male',
|
||||
emoji: '🎙️',
|
||||
previewText: "Hey there! I'm Puck, your friendly news companion. Let me read the latest stories for you."
|
||||
},
|
||||
{
|
||||
name: VoiceName.Charon,
|
||||
label: 'Charon',
|
||||
description: 'Deep & Authoritative, Male',
|
||||
emoji: '🎭',
|
||||
previewText: "Good day. I am Charon. I bring you the news with clarity and gravitas."
|
||||
},
|
||||
{
|
||||
name: VoiceName.Kore,
|
||||
label: 'Kore',
|
||||
description: 'Soft & Calming, Female',
|
||||
emoji: '🌸',
|
||||
previewText: "Hello, I'm Kore. Let me gently guide you through today's stories in a calm, relaxing tone."
|
||||
},
|
||||
{
|
||||
name: VoiceName.Fenrir,
|
||||
label: 'Fenrir',
|
||||
description: 'British Style, Male',
|
||||
emoji: '🎩',
|
||||
previewText: "Good evening. I'm Fenrir, bringing you the news with a touch of British sophistication."
|
||||
},
|
||||
{
|
||||
name: VoiceName.Zephyr,
|
||||
label: 'Zephyr',
|
||||
description: 'Clear & Professional, Female',
|
||||
emoji: '✨',
|
||||
previewText: "Hi, I'm Zephyr. I deliver your news with crystal clear precision and professionalism."
|
||||
},
|
||||
{
|
||||
name: VoiceName.Aoede,
|
||||
label: 'Aoede',
|
||||
description: 'Confident & Warm, Female',
|
||||
emoji: '🌟',
|
||||
previewText: "Hello! I'm Aoede. I'll share the day's stories with warmth and confidence."
|
||||
},
|
||||
];
|
||||
|
||||
export const MIN_SPEED = 0.5;
|
||||
@@ -15,3 +51,54 @@ export const MAX_SPEED = 3.5;
|
||||
export const SPEED_STEP = 0.5;
|
||||
|
||||
export const SAMPLE_RATE = 24000;
|
||||
|
||||
// Voice Moods - preset configurations for different listening contexts
|
||||
export const VOICE_MOODS: import('./types').VoiceMoodConfig[] = [
|
||||
{
|
||||
id: 'neutral',
|
||||
label: 'Neutral',
|
||||
emoji: '🎧',
|
||||
description: 'Balanced, everyday listening',
|
||||
recommendedVoice: VoiceName.Puck,
|
||||
recommendedSpeed: 1.0
|
||||
},
|
||||
{
|
||||
id: 'energetic',
|
||||
label: 'Energetic',
|
||||
emoji: '⚡',
|
||||
description: 'Upbeat morning news',
|
||||
recommendedVoice: VoiceName.Zephyr,
|
||||
recommendedSpeed: 1.2
|
||||
},
|
||||
{
|
||||
id: 'calm',
|
||||
label: 'Calm',
|
||||
emoji: '🌿',
|
||||
description: 'Relaxed, peaceful listening',
|
||||
recommendedVoice: VoiceName.Kore,
|
||||
recommendedSpeed: 0.9
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
label: 'Professional',
|
||||
emoji: '💼',
|
||||
description: 'Business & serious topics',
|
||||
recommendedVoice: VoiceName.Charon,
|
||||
recommendedSpeed: 1.0
|
||||
},
|
||||
{
|
||||
id: 'bedtime',
|
||||
label: 'Bedtime',
|
||||
emoji: '🌙',
|
||||
description: 'Soothing for sleep',
|
||||
recommendedVoice: VoiceName.Kore,
|
||||
recommendedSpeed: 0.8
|
||||
}
|
||||
];
|
||||
|
||||
// Smart Speed multipliers based on article complexity
|
||||
export const SMART_SPEED_MULTIPLIERS = {
|
||||
simple: 1.2, // Faster for simple content
|
||||
moderate: 1.0, // Normal speed
|
||||
complex: 0.85 // Slower for complex content
|
||||
};
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NewsCaster AI</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class'
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
|
||||
2966
package-lock.json
generated
Normal file
2966
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.30.0",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
}
|
||||
135
services/audioExportService.ts
Normal file
135
services/audioExportService.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Article, AudioSegment } from '../types';
|
||||
import { SAMPLE_RATE } from '../constants';
|
||||
|
||||
/**
|
||||
* Combines multiple audio segments into a single downloadable file.
|
||||
* Since browser-based MP3 encoding is complex, we export as WAV format
|
||||
* which is widely supported and maintains quality.
|
||||
*/
|
||||
export const exportArticleAudio = async (
|
||||
article: Article,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<Blob> => {
|
||||
const audioSegments = article.segments.filter(seg => seg.audioUrl);
|
||||
|
||||
if (audioSegments.length === 0) {
|
||||
throw new Error('No audio segments available for export');
|
||||
}
|
||||
|
||||
// Fetch all audio data
|
||||
const audioBuffers: ArrayBuffer[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (let i = 0; i < audioSegments.length; i++) {
|
||||
const segment = audioSegments[i];
|
||||
if (!segment.audioUrl) continue;
|
||||
|
||||
try {
|
||||
const response = await fetch(segment.audioUrl);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
audioBuffers.push(arrayBuffer);
|
||||
|
||||
// Parse WAV to get PCM data length (skip 44-byte header)
|
||||
totalLength += arrayBuffer.byteLength - 44;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(Math.round(((i + 1) / audioSegments.length) * 100));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to fetch segment ${i}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (audioBuffers.length === 0) {
|
||||
throw new Error('Failed to fetch any audio segments');
|
||||
}
|
||||
|
||||
// Combine PCM data from all WAV files
|
||||
const combinedPcm = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const buffer of audioBuffers) {
|
||||
// Skip WAV header (44 bytes) and copy PCM data
|
||||
const pcmData = new Uint8Array(buffer, 44);
|
||||
combinedPcm.set(pcmData, offset);
|
||||
offset += pcmData.length;
|
||||
}
|
||||
|
||||
// Create new WAV file with combined data
|
||||
return createWavFile(combinedPcm, SAMPLE_RATE);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a WAV file from PCM data
|
||||
*/
|
||||
const createWavFile = (pcmData: Uint8Array, sampleRate: number): Blob => {
|
||||
const numChannels = 1;
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
|
||||
const blockAlign = numChannels * (bitsPerSample / 8);
|
||||
const dataSize = pcmData.length;
|
||||
const fileSize = 44 + dataSize;
|
||||
|
||||
const buffer = new ArrayBuffer(fileSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// RIFF header
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, fileSize - 8, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
|
||||
// fmt chunk
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); // chunk size
|
||||
view.setUint16(20, 1, true); // PCM format
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, byteRate, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, bitsPerSample, true);
|
||||
|
||||
// data chunk
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// PCM data
|
||||
const outputArray = new Uint8Array(buffer);
|
||||
outputArray.set(pcmData, 44);
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
};
|
||||
|
||||
const writeString = (view: DataView, offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers download of the audio file
|
||||
*/
|
||||
export const downloadAudio = (blob: Blob, filename: string): void => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename.endsWith('.wav') ? filename : `${filename}.wav`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export and download article audio with progress tracking
|
||||
*/
|
||||
export const exportAndDownloadArticle = async (
|
||||
article: Article,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> => {
|
||||
const blob = await exportArticleAudio(article, onProgress);
|
||||
const safeTitle = article.title
|
||||
.replace(/[^a-zA-Z0-9\s]/g, '')
|
||||
.replace(/\s+/g, '_')
|
||||
.substring(0, 50);
|
||||
downloadAudio(blob, `${safeTitle}_newscaster.wav`);
|
||||
};
|
||||
@@ -1,24 +1,17 @@
|
||||
import { GoogleGenAI, Modality } from '@google/genai';
|
||||
import { VoiceName } from '../types';
|
||||
import { normalizeUrl } from '../utils/url';
|
||||
|
||||
const getAiClient = () => {
|
||||
const apiKey = process.env.API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("API Key is missing");
|
||||
}
|
||||
return new GoogleGenAI({ apiKey });
|
||||
};
|
||||
const apiKey = import.meta.env.VITE_API_KEY;
|
||||
|
||||
/**
|
||||
* Helper to ensure URL has protocol.
|
||||
* Proxies often fail if 'http/https' is missing.
|
||||
*/
|
||||
const normalizeUrl = (url: string) => {
|
||||
let cleanUrl = url.trim();
|
||||
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
|
||||
return `https://${cleanUrl}`;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"Gemini API key is missing. Set VITE_API_KEY in your .env.local file (e.g., VITE_API_KEY=your_key_here)."
|
||||
);
|
||||
}
|
||||
return cleanUrl;
|
||||
|
||||
return new GoogleGenAI({ apiKey });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -218,6 +211,87 @@ export const extractArticleContent = async (url: string): Promise<{ title: strin
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a 30-second summary of an article.
|
||||
*/
|
||||
export const generateArticleSummary = async (text: string, title: string): Promise<string> => {
|
||||
const ai = getAiClient();
|
||||
|
||||
const prompt = `
|
||||
You are a professional news summarizer. Create a brief, engaging summary of the following article.
|
||||
|
||||
TITLE: ${title}
|
||||
|
||||
ARTICLE TEXT:
|
||||
${text.substring(0, 8000)} ${text.length > 8000 ? '...[truncated]' : ''}
|
||||
|
||||
RULES:
|
||||
1. Create a summary that can be read aloud in about 30 seconds (approximately 80-100 words)
|
||||
2. Start with the most important/newsworthy point
|
||||
3. Include 2-3 key facts or takeaways
|
||||
4. Use clear, conversational language suitable for audio
|
||||
5. Do NOT use bullet points or formatting - write in flowing sentences
|
||||
6. End with a brief mention of why this matters
|
||||
|
||||
Output ONLY the summary text, nothing else.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-2.5-flash',
|
||||
contents: prompt,
|
||||
config: {
|
||||
temperature: 0.3,
|
||||
}
|
||||
});
|
||||
|
||||
return response.text?.trim() || "Unable to generate summary.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes text complexity for Smart Speed feature.
|
||||
*/
|
||||
export const analyzeTextComplexity = (text: string): {
|
||||
complexity: 'simple' | 'moderate' | 'complex';
|
||||
wordCount: number;
|
||||
estimatedReadTime: number;
|
||||
} => {
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0);
|
||||
const wordCount = words.length;
|
||||
|
||||
// Calculate average word length
|
||||
const avgWordLength = words.reduce((sum, w) => sum + w.length, 0) / wordCount;
|
||||
|
||||
// Count sentences (rough estimate)
|
||||
const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||||
const avgSentenceLength = wordCount / Math.max(1, sentences);
|
||||
|
||||
// Count complex indicators
|
||||
const complexWords = words.filter(w => w.length > 10).length;
|
||||
const complexWordRatio = complexWords / wordCount;
|
||||
|
||||
// Score complexity (0-100)
|
||||
let score = 0;
|
||||
score += Math.min(30, avgWordLength * 4); // Longer words = more complex
|
||||
score += Math.min(30, avgSentenceLength * 1.5); // Longer sentences = more complex
|
||||
score += Math.min(40, complexWordRatio * 200); // More complex words = more complex
|
||||
|
||||
// Determine complexity level
|
||||
let complexity: 'simple' | 'moderate' | 'complex';
|
||||
if (score < 35) {
|
||||
complexity = 'simple';
|
||||
} else if (score < 55) {
|
||||
complexity = 'moderate';
|
||||
} else {
|
||||
complexity = 'complex';
|
||||
}
|
||||
|
||||
// Estimate read time (words per minute varies by complexity)
|
||||
const wpm = complexity === 'simple' ? 180 : complexity === 'moderate' ? 150 : 130;
|
||||
const estimatedReadTime = Math.ceil(wordCount / wpm);
|
||||
|
||||
return { complexity, wordCount, estimatedReadTime };
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates speech audio from text.
|
||||
*/
|
||||
|
||||
24
services/objectUrlManager.ts
Normal file
24
services/objectUrlManager.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
const trackedUrls = new Set<string>();
|
||||
|
||||
export const createTrackedObjectUrl = (blob: Blob): string => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
trackedUrls.add(url);
|
||||
return url;
|
||||
};
|
||||
|
||||
export const revokeTrackedObjectUrl = (url?: string) => {
|
||||
if (!url) return;
|
||||
if (trackedUrls.has(url)) {
|
||||
trackedUrls.delete(url);
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const revokeMultipleObjectUrls = (urls: (string | undefined)[]) => {
|
||||
urls.forEach(revokeTrackedObjectUrl);
|
||||
};
|
||||
|
||||
export const revokeAllTrackedObjectUrls = () => {
|
||||
trackedUrls.forEach(url => URL.revokeObjectURL(url));
|
||||
trackedUrls.clear();
|
||||
};
|
||||
184
services/rssService.ts
Normal file
184
services/rssService.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { RSSFeed, RSSArticle } from '../types';
|
||||
import { saveRSSFeed, updateRSSFeedLastFetched } from './storageService';
|
||||
|
||||
// CORS proxy for RSS feeds
|
||||
const RSS_PROXY = (url: string) => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`;
|
||||
|
||||
/**
|
||||
* Parse RSS/Atom feed XML into articles
|
||||
*/
|
||||
const parseRSSFeed = (xml: string, feedId: string): RSSArticle[] => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
|
||||
const articles: RSSArticle[] = [];
|
||||
|
||||
// Try RSS 2.0 format
|
||||
const items = doc.querySelectorAll('item');
|
||||
if (items.length > 0) {
|
||||
items.forEach(item => {
|
||||
const title = item.querySelector('title')?.textContent || '';
|
||||
const link = item.querySelector('link')?.textContent || '';
|
||||
const description = item.querySelector('description')?.textContent || '';
|
||||
const pubDate = item.querySelector('pubDate')?.textContent || '';
|
||||
|
||||
if (title && link) {
|
||||
articles.push({
|
||||
title: title.trim(),
|
||||
url: link.trim(),
|
||||
description: description.replace(/<[^>]*>/g, '').substring(0, 200),
|
||||
pubDate,
|
||||
feedId
|
||||
});
|
||||
}
|
||||
});
|
||||
return articles;
|
||||
}
|
||||
|
||||
// Try Atom format
|
||||
const entries = doc.querySelectorAll('entry');
|
||||
entries.forEach(entry => {
|
||||
const title = entry.querySelector('title')?.textContent || '';
|
||||
const link = entry.querySelector('link')?.getAttribute('href') || '';
|
||||
const summary = entry.querySelector('summary')?.textContent || '';
|
||||
const published = entry.querySelector('published')?.textContent || '';
|
||||
|
||||
if (title && link) {
|
||||
articles.push({
|
||||
title: title.trim(),
|
||||
url: link.trim(),
|
||||
description: summary.replace(/<[^>]*>/g, '').substring(0, 200),
|
||||
pubDate: published,
|
||||
feedId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return articles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get feed title from XML
|
||||
*/
|
||||
const getFeedTitle = (xml: string): string => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
|
||||
// RSS 2.0
|
||||
const channelTitle = doc.querySelector('channel > title')?.textContent;
|
||||
if (channelTitle) return channelTitle.trim();
|
||||
|
||||
// Atom
|
||||
const feedTitle = doc.querySelector('feed > title')?.textContent;
|
||||
if (feedTitle) return feedTitle.trim();
|
||||
|
||||
return 'Unknown Feed';
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch and parse RSS feed
|
||||
*/
|
||||
export const fetchRSSFeed = async (feedUrl: string): Promise<{
|
||||
feed: RSSFeed;
|
||||
articles: RSSArticle[];
|
||||
}> => {
|
||||
const response = await fetch(RSS_PROXY(feedUrl));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch feed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const feedId = uuidv4();
|
||||
const title = getFeedTitle(xml);
|
||||
const articles = parseRSSFeed(xml, feedId);
|
||||
|
||||
const feed: RSSFeed = {
|
||||
id: feedId,
|
||||
url: feedUrl,
|
||||
title,
|
||||
articleCount: articles.length,
|
||||
lastFetched: Date.now(),
|
||||
isActive: true,
|
||||
addedAt: Date.now()
|
||||
};
|
||||
|
||||
return { feed, articles };
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh articles from an existing feed
|
||||
*/
|
||||
export const refreshFeed = async (feed: RSSFeed): Promise<RSSArticle[]> => {
|
||||
const response = await fetch(RSS_PROXY(feed.url));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to refresh feed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const articles = parseRSSFeed(xml, feed.id);
|
||||
|
||||
updateRSSFeedLastFetched(feed.id);
|
||||
|
||||
return articles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate RSS feed URL
|
||||
*/
|
||||
export const validateRSSUrl = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(RSS_PROXY(url));
|
||||
if (!response.ok) return false;
|
||||
|
||||
const text = await response.text();
|
||||
// Check if it looks like RSS/Atom
|
||||
return text.includes('<rss') || text.includes('<feed') || text.includes('<channel');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Popular RSS feed suggestions organized by category
|
||||
export const SUGGESTED_FEEDS = [
|
||||
// General News
|
||||
{ name: 'BBC News', url: 'https://feeds.bbci.co.uk/news/rss.xml', category: 'news' },
|
||||
{ name: 'NPR News', url: 'https://feeds.npr.org/1001/rss.xml', category: 'news' },
|
||||
{ name: 'Reuters', url: 'https://www.reutersagency.com/feed/', category: 'news' },
|
||||
{ name: 'The Guardian', url: 'https://www.theguardian.com/world/rss', category: 'news' },
|
||||
{ name: 'Associated Press', url: 'https://feeds.apnews.com/rss/topnews', category: 'news' },
|
||||
{ name: 'CNN World', url: 'http://rss.cnn.com/rss/cnn_world.rss', category: 'news' },
|
||||
{ name: 'Al Jazeera', url: 'https://www.aljazeera.com/xml/rss/all.xml', category: 'news' },
|
||||
{ name: 'The New York Times', url: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', category: 'news' },
|
||||
{ name: 'Washington Post', url: 'https://feeds.washingtonpost.com/rss/world', category: 'news' },
|
||||
|
||||
// Technology
|
||||
{ name: 'TechCrunch', url: 'https://techcrunch.com/feed/', category: 'tech' },
|
||||
{ name: 'Hacker News', url: 'https://hnrss.org/frontpage', category: 'tech' },
|
||||
{ name: 'Ars Technica', url: 'https://feeds.arstechnica.com/arstechnica/index', category: 'tech' },
|
||||
{ name: 'The Verge', url: 'https://www.theverge.com/rss/index.xml', category: 'tech' },
|
||||
{ name: 'Wired', url: 'https://www.wired.com/feed/rss', category: 'tech' },
|
||||
{ name: 'MIT Technology Review', url: 'https://www.technologyreview.com/feed/', category: 'tech' },
|
||||
{ name: 'Engadget', url: 'https://www.engadget.com/rss.xml', category: 'tech' },
|
||||
{ name: 'ZDNet', url: 'https://www.zdnet.com/news/rss.xml', category: 'tech' },
|
||||
|
||||
// Business & Finance
|
||||
{ name: 'Financial Times', url: 'https://www.ft.com/?format=rss', category: 'business' },
|
||||
{ name: 'Bloomberg', url: 'https://feeds.bloomberg.com/markets/news.rss', category: 'business' },
|
||||
{ name: 'The Economist', url: 'https://www.economist.com/finance-and-economics/rss.xml', category: 'business' },
|
||||
{ name: 'Forbes', url: 'https://www.forbes.com/real-time/feed2/', category: 'business' },
|
||||
{ name: 'MarketWatch', url: 'https://feeds.marketwatch.com/marketwatch/topstories/', category: 'business' },
|
||||
|
||||
// Science
|
||||
{ name: 'Nature News', url: 'https://www.nature.com/nature.rss', category: 'science' },
|
||||
{ name: 'Scientific American', url: 'https://www.scientificamerican.com/feed/', category: 'science' },
|
||||
{ name: 'ScienceDaily', url: 'https://www.sciencedaily.com/rss/all.xml', category: 'science' },
|
||||
{ name: 'Phys.org', url: 'https://phys.org/rss-feed/', category: 'science' },
|
||||
{ name: 'Space.com', url: 'https://www.space.com/feeds/all', category: 'science' },
|
||||
|
||||
// International
|
||||
{ name: 'The Japan Times', url: 'https://www.japantimes.co.jp/feed/', category: 'international' },
|
||||
{ name: 'South China Morning Post', url: 'https://www.scmp.com/rss/91/feed', category: 'international' },
|
||||
{ name: 'Deutsche Welle', url: 'https://rss.dw.com/rdf/rss-en-all', category: 'international' },
|
||||
{ name: 'France 24', url: 'https://www.france24.com/en/rss', category: 'international' }
|
||||
];
|
||||
213
services/storageService.ts
Normal file
213
services/storageService.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { ReadingStats, Bookmark, RSSFeed, VoiceName } from '../types';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
STATS: 'newscaster_stats',
|
||||
BOOKMARKS: 'newscaster_bookmarks',
|
||||
RSS_FEEDS: 'newscaster_rss_feeds',
|
||||
LAST_PLAYED: 'newscaster_last_played'
|
||||
};
|
||||
|
||||
// Default stats
|
||||
const DEFAULT_STATS: ReadingStats = {
|
||||
totalArticlesRead: 0,
|
||||
totalMinutesListened: 0,
|
||||
totalWordsRead: 0,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
lastReadDate: '',
|
||||
articlesPerDay: {},
|
||||
favoriteVoice: VoiceName.Puck,
|
||||
voiceUsage: {
|
||||
[VoiceName.Puck]: 0,
|
||||
[VoiceName.Charon]: 0,
|
||||
[VoiceName.Kore]: 0,
|
||||
[VoiceName.Fenrir]: 0,
|
||||
[VoiceName.Zephyr]: 0,
|
||||
[VoiceName.Aoede]: 0
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== STATS ====================
|
||||
|
||||
export const getStats = (): ReadingStats => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.STATS);
|
||||
if (stored) {
|
||||
return { ...DEFAULT_STATS, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load stats:', e);
|
||||
}
|
||||
return DEFAULT_STATS;
|
||||
};
|
||||
|
||||
export const saveStats = (stats: ReadingStats): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.STATS, JSON.stringify(stats));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save stats:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateStatsOnArticleComplete = (
|
||||
wordCount: number,
|
||||
minutesListened: number,
|
||||
voice: VoiceName
|
||||
): ReadingStats => {
|
||||
const stats = getStats();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Update totals
|
||||
stats.totalArticlesRead += 1;
|
||||
stats.totalMinutesListened += minutesListened;
|
||||
stats.totalWordsRead += wordCount;
|
||||
|
||||
// Update daily count
|
||||
stats.articlesPerDay[today] = (stats.articlesPerDay[today] || 0) + 1;
|
||||
|
||||
// Update streak
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||||
if (stats.lastReadDate === yesterday) {
|
||||
stats.currentStreak += 1;
|
||||
} else if (stats.lastReadDate !== today) {
|
||||
stats.currentStreak = 1;
|
||||
}
|
||||
stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
|
||||
stats.lastReadDate = today;
|
||||
|
||||
// Update voice usage
|
||||
stats.voiceUsage[voice] = (stats.voiceUsage[voice] || 0) + 1;
|
||||
|
||||
// Determine favorite voice
|
||||
let maxUsage = 0;
|
||||
Object.entries(stats.voiceUsage).forEach(([v, count]) => {
|
||||
if (count > maxUsage) {
|
||||
maxUsage = count;
|
||||
stats.favoriteVoice = v as VoiceName;
|
||||
}
|
||||
});
|
||||
|
||||
saveStats(stats);
|
||||
return stats;
|
||||
};
|
||||
|
||||
// ==================== BOOKMARKS ====================
|
||||
|
||||
export const getBookmarks = (): Bookmark[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.BOOKMARKS);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load bookmarks:', e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const saveBookmark = (bookmark: Bookmark): void => {
|
||||
try {
|
||||
const bookmarks = getBookmarks();
|
||||
// Remove existing bookmark for same URL
|
||||
const filtered = bookmarks.filter(b => b.url !== bookmark.url);
|
||||
filtered.unshift(bookmark); // Add to beginning
|
||||
// Keep only last 50 bookmarks
|
||||
const trimmed = filtered.slice(0, 50);
|
||||
localStorage.setItem(STORAGE_KEYS.BOOKMARKS, JSON.stringify(trimmed));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save bookmark:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeBookmark = (url: string): void => {
|
||||
try {
|
||||
const bookmarks = getBookmarks();
|
||||
const filtered = bookmarks.filter(b => b.url !== url);
|
||||
localStorage.setItem(STORAGE_KEYS.BOOKMARKS, JSON.stringify(filtered));
|
||||
} catch (e) {
|
||||
console.warn('Failed to remove bookmark:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getBookmarkForUrl = (url: string): Bookmark | undefined => {
|
||||
const bookmarks = getBookmarks();
|
||||
return bookmarks.find(b => b.url === url);
|
||||
};
|
||||
|
||||
// ==================== RSS FEEDS ====================
|
||||
|
||||
export const getRSSFeeds = (): RSSFeed[] => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.RSS_FEEDS);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load RSS feeds:', e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const saveRSSFeed = (feed: RSSFeed): void => {
|
||||
try {
|
||||
const feeds = getRSSFeeds();
|
||||
const existing = feeds.findIndex(f => f.id === feed.id);
|
||||
if (existing >= 0) {
|
||||
feeds[existing] = feed;
|
||||
} else {
|
||||
feeds.push(feed);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEYS.RSS_FEEDS, JSON.stringify(feeds));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save RSS feed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeRSSFeed = (feedId: string): void => {
|
||||
try {
|
||||
const feeds = getRSSFeeds();
|
||||
const filtered = feeds.filter(f => f.id !== feedId);
|
||||
localStorage.setItem(STORAGE_KEYS.RSS_FEEDS, JSON.stringify(filtered));
|
||||
} catch (e) {
|
||||
console.warn('Failed to remove RSS feed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRSSFeedLastFetched = (feedId: string): void => {
|
||||
try {
|
||||
const feeds = getRSSFeeds();
|
||||
const feed = feeds.find(f => f.id === feedId);
|
||||
if (feed) {
|
||||
feed.lastFetched = Date.now();
|
||||
localStorage.setItem(STORAGE_KEYS.RSS_FEEDS, JSON.stringify(feeds));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to update RSS feed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== LAST PLAYED ====================
|
||||
|
||||
export const saveLastPlayed = (articleId: string, segmentIndex: number): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.LAST_PLAYED, JSON.stringify({
|
||||
articleId,
|
||||
segmentIndex,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save last played:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getLastPlayed = (): { articleId: string; segmentIndex: number } | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.LAST_PLAYED);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load last played:', e);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -16,6 +16,6 @@
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
73
types.ts
73
types.ts
@@ -22,7 +22,7 @@ export enum PlaybackStatus {
|
||||
export interface AudioSegment {
|
||||
id: string;
|
||||
text: string;
|
||||
audioUrl?: string; // Blob URL for this specific segment
|
||||
audioUrl?: string;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
@@ -31,13 +31,20 @@ export interface Article {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
// We keep the full text for display/reference
|
||||
text: string;
|
||||
// We split content into segments for faster playback
|
||||
segments: AudioSegment[];
|
||||
currentSegmentIndex: number;
|
||||
status: PlaybackStatus;
|
||||
errorMessage?: string;
|
||||
// New fields for enhanced features
|
||||
summary?: string;
|
||||
summaryAudioUrl?: string;
|
||||
isSummaryLoading?: boolean;
|
||||
complexity?: 'simple' | 'moderate' | 'complex';
|
||||
wordCount?: number;
|
||||
estimatedReadTime?: number; // in minutes
|
||||
addedAt?: number; // timestamp
|
||||
fromRssFeed?: string; // RSS feed ID if applicable
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
@@ -45,6 +52,8 @@ export interface PlayerState {
|
||||
playbackRate: number;
|
||||
currentArticleId: string | null;
|
||||
selectedVoice: VoiceName;
|
||||
voiceMood: VoiceMood;
|
||||
smartSpeedEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ReaderSettings {
|
||||
@@ -53,4 +62,62 @@ export interface ReaderSettings {
|
||||
lineHeight: 'normal' | 'relaxed' | 'loose';
|
||||
fontFamily: 'sans' | 'serif' | 'mono';
|
||||
autoScroll: boolean;
|
||||
readingTone: 'clean' | 'sepia' | 'night';
|
||||
pageWidth: 'cozy' | 'standard' | 'wide';
|
||||
zenMode: boolean;
|
||||
}
|
||||
|
||||
// Voice Moods - preset configurations
|
||||
export type VoiceMood = 'neutral' | 'energetic' | 'calm' | 'professional' | 'bedtime';
|
||||
|
||||
export interface VoiceMoodConfig {
|
||||
id: VoiceMood;
|
||||
label: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
recommendedVoice: VoiceName;
|
||||
recommendedSpeed: number;
|
||||
}
|
||||
|
||||
// Reading Stats
|
||||
export interface ReadingStats {
|
||||
totalArticlesRead: number;
|
||||
totalMinutesListened: number;
|
||||
totalWordsRead: number;
|
||||
currentStreak: number; // days
|
||||
longestStreak: number;
|
||||
lastReadDate: string; // ISO date string
|
||||
articlesPerDay: Record<string, number>; // date -> count
|
||||
favoriteVoice: VoiceName;
|
||||
voiceUsage: Record<VoiceName, number>;
|
||||
}
|
||||
|
||||
// Bookmarks for resume functionality
|
||||
export interface Bookmark {
|
||||
articleId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
segmentIndex: number;
|
||||
savedAt: number; // timestamp
|
||||
progress: number; // 0-100 percentage
|
||||
}
|
||||
|
||||
// RSS Feed subscriptions
|
||||
export interface RSSFeed {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
lastFetched?: number;
|
||||
articleCount?: number;
|
||||
isActive: boolean;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface RSSArticle {
|
||||
title: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
pubDate?: string;
|
||||
feedId: string;
|
||||
}
|
||||
|
||||
28
utils/url.test.ts
Normal file
28
utils/url.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDisplayUrl, normalizeUrl } from './url';
|
||||
|
||||
describe('normalizeUrl', () => {
|
||||
it('adds https protocol when missing', () => {
|
||||
expect(normalizeUrl('example.com/page')).toBe('https://example.com/page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayUrl', () => {
|
||||
it('returns hostname and normalized href for valid URLs', () => {
|
||||
const result = getDisplayUrl('https://example.com/path');
|
||||
expect(result.hostname).toBe('example.com');
|
||||
expect(result.href).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
it('normalizes URLs without protocol for display', () => {
|
||||
const result = getDisplayUrl('example.com/path');
|
||||
expect(result.hostname).toBe('example.com');
|
||||
expect(result.href).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
it('falls back to raw URL when parsing fails', () => {
|
||||
const result = getDisplayUrl('not a url');
|
||||
expect(result.hostname).toBe('not a url');
|
||||
expect(result.href).toBe('not a url');
|
||||
});
|
||||
});
|
||||
23
utils/url.ts
Normal file
23
utils/url.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const normalizeUrl = (url: string) => {
|
||||
let cleanUrl = url.trim();
|
||||
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
|
||||
return `https://${cleanUrl}`;
|
||||
}
|
||||
return cleanUrl;
|
||||
};
|
||||
|
||||
export const getDisplayUrl = (url: string): { href: string; hostname: string } => {
|
||||
const normalized = normalizeUrl(url);
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
return { href: normalized, hostname: parsed.hostname };
|
||||
} catch {
|
||||
try {
|
||||
const fallback = new URL(url);
|
||||
return { href: url, hostname: fallback.hostname };
|
||||
} catch {
|
||||
return { href: url, hostname: url };
|
||||
}
|
||||
}
|
||||
};
|
||||
1
vite-env.d.ts
vendored
Normal file
1
vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,16 +1,9 @@
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
const env = loadEnv(mode, (process as any).cwd(), '');
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
define: {
|
||||
// This allows the app code to continue using process.env.API_KEY
|
||||
// even though it is running in the browser.
|
||||
'process.env.API_KEY': JSON.stringify(env.API_KEY)
|
||||
}
|
||||
};
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
allowedHosts: ['debianvm.kangaroo-eel.ts.net']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user