Merge pull request #4 from Tony0410/codex/suggest-app-vibe-enhancements

Add vibey onboarding, queue controls, and ambient reader modes
This commit is contained in:
Anthony
2025-11-27 21:31:55 +08:00
committed by GitHub
4 changed files with 418 additions and 120 deletions

346
App.tsx
View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2 } from 'lucide-react'; import { Plus, Play, Pause, SkipForward, SkipBack, Volume2, Gauge, Settings, Moon, Sun, Keyboard, Loader2, Sparkles, Shuffle, History as HistoryIcon, GripVertical, Waves } from 'lucide-react';
import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types'; import { Article, PlaybackStatus, PlayerState, VoiceName, AudioSegment, ReaderSettings } from './types';
import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants'; import { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
import { extractArticleContent, generateSpeechFromText } from './services/geminiService'; import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
@@ -16,8 +16,11 @@ export default function App() {
// -- State -- // -- State --
const [inputUrl, setInputUrl] = useState(''); const [inputUrl, setInputUrl] = useState('');
const [queue, setQueue] = useState<Article[]>([]); const [queue, setQueue] = useState<Article[]>([]);
const [history, setHistory] = useState<Article[]>([]);
const [viewId, setViewId] = useState<string | null>(null); const [viewId, setViewId] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [activeList, setActiveList] = useState<'queue' | 'history'>('queue');
const [draggingId, setDraggingId] = useState<string | null>(null);
const [playerState, setPlayerState] = useState<PlayerState>({ const [playerState, setPlayerState] = useState<PlayerState>({
isPlaying: false, isPlaying: false,
@@ -31,9 +34,19 @@ export default function App() {
fontSize: 'lg', fontSize: 'lg',
lineHeight: 'relaxed', lineHeight: 'relaxed',
fontFamily: 'serif', fontFamily: 'serif',
autoScroll: true autoScroll: true,
readingTone: 'clean',
pageWidth: 'standard',
zenMode: false
}); });
const starterPicks = [
{ label: 'Tech trends', url: 'https://news.ycombinator.com' },
{ label: 'Global headlines', url: 'https://www.reuters.com/world/' },
{ label: 'Science daily', url: 'https://www.sciencedaily.com/news/top/science/' },
{ label: 'Longform feature', url: 'https://www.theguardian.com/world/interactive/2024/jan/01' }
];
// -- Refs -- // -- Refs --
const audioRef = useRef<HTMLAudioElement>(new Audio()); const audioRef = useRef<HTMLAudioElement>(new Audio());
const processingRef = useRef<Set<string>>(new Set()); const processingRef = useRef<Set<string>>(new Set());
@@ -148,33 +161,40 @@ export default function App() {
})); }));
}, [playerState.currentArticleId]); }, [playerState.currentArticleId]);
const handleAddUrl = async () => { const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean } = {}) => {
if (!inputUrl.trim()) return; 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 id = uuidv4();
const newArticle: Article = { const newArticle: Article = {
id, id,
url: inputUrl, url: normalizedUrl,
title: 'Fetching info...', title: 'Fetching info...',
text: '', text: '',
segments: [], segments: [],
currentSegmentIndex: 0, currentSegmentIndex: 0,
status: PlaybackStatus.LOADING_TEXT status: PlaybackStatus.LOADING_TEXT
}; };
setQueue(prev => [...prev, newArticle]); setQueue(prev => [...prev, newArticle]);
setInputUrl(''); if (!playerState.isPlaying || options.pinView) setViewId(id);
if (!playerState.isPlaying) setViewId(id);
try { try {
const { title, text } = await extractArticleContent(newArticle.url); const { title, text } = await extractArticleContent(newArticle.url);
const segments = segmentText(text); const segments = segmentText(text);
if (title) { if (title) {
const titleSegment = segmentText(title)[0]; const titleSegment = segmentText(title)[0];
if (titleSegment) segments.unshift(titleSegment); if (titleSegment) segments.unshift(titleSegment);
} }
updateArticle(id, { updateArticle(id, {
title, title,
text, text,
@@ -188,10 +208,10 @@ export default function App() {
processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice); processSegmentAudio(id, segments[i].id, segments[i].text, playerState.selectedVoice);
} }
updateArticle(id, { status: PlaybackStatus.READY }); updateArticle(id, { status: PlaybackStatus.READY });
setQueue(prev => { setQueue(prev => {
const current = prev.find(a => a.id === id); const current = prev.find(a => a.id === id);
if (current && !playerState.isPlaying) { if (current && (!playerState.isPlaying || options.autoPlay)) {
playArticle(id); playArticle(id);
} }
return prev; return prev;
@@ -201,13 +221,28 @@ export default function App() {
} }
} catch (error: any) { } catch (error: any) {
updateArticle(id, { updateArticle(id, {
status: PlaybackStatus.ERROR, status: PlaybackStatus.ERROR,
errorMessage: error.message || "Failed to load article" errorMessage: error.message || "Failed to load article"
}); });
} }
}; };
const handleAddUrl = async () => {
await enqueueArticle(inputUrl, { autoPlay: !playerState.isPlaying, pinView: true });
setInputUrl('');
};
const handleStarterPick = async (url: string) => {
await enqueueArticle(url, { autoPlay: true, pinView: true });
setActiveList('queue');
};
const handleRandomStarter = () => {
const pick = starterPicks[Math.floor(Math.random() * starterPicks.length)];
if (pick) handleStarterPick(pick.url);
};
// -- Playback Control -- // -- Playback Control --
const playArticle = useCallback(async (id: string) => { const playArticle = useCallback(async (id: string) => {
@@ -248,6 +283,34 @@ export default function App() {
}); });
}, [playerState.currentArticleId]); }, [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) => { const handleSegmentSelect = useCallback((articleId: string, index: number) => {
setPlayerState(prev => ({ setPlayerState(prev => ({
...prev, ...prev,
@@ -398,6 +461,15 @@ export default function App() {
}; };
}, []); }, []);
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 handleSpeedChange = (newSpeed: number) => {
const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed)); const speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
setPlayerState(prev => ({ ...prev, playbackRate: speed })); setPlayerState(prev => ({ ...prev, playbackRate: speed }));
@@ -508,6 +580,60 @@ export default function App() {
))} ))}
</div> </div>
</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 */} {/* Shortcuts Hint */}
<div className="pt-4 border-t border-slate-100 dark:border-slate-800"> <div className="pt-4 border-t border-slate-100 dark:border-slate-800">
@@ -527,64 +653,135 @@ export default function App() {
{/* Left Column: Controls & Queue */} {/* Left Column: Controls & Queue */}
<div className="lg:col-span-5 space-y-6"> <div className="lg:col-span-5 space-y-6">
{/* Input */} {/* 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"> <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">
<input <div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
type="url" <input
placeholder="Paste article URL here..." type="url"
className="flex-grow py-3 outline-none text-slate-700 dark:text-slate-200 bg-transparent placeholder:text-slate-400 min-w-0" placeholder="Paste article URL here..."
value={inputUrl} 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"
onChange={(e) => setInputUrl(e.target.value)} value={inputUrl}
onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()} onChange={(e) => setInputUrl(e.target.value)}
/> onKeyDown={(e) => e.key === 'Enter' && handleAddUrl()}
<button />
onClick={handleAddUrl} <div className="flex items-center gap-2 self-end sm:self-auto">
disabled={!inputUrl.trim()} <button
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" onClick={handleAddUrl}
> disabled={!inputUrl.trim()}
<Plus className="w-4 h-4" /> 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"
<span className="hidden sm:inline">Queue</span> >
</button> <Plus className="w-4 h-4" />
<span className="hidden sm:inline">Queue</span>
</button>
<button
onClick={() => handleRandomStarter()}
className="bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-200 px-4 py-3 rounded-xl font-medium flex items-center gap-2 transition-all"
>
<Shuffle className="w-4 h-4" /> Surprise me
</button>
</div>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 px-3 pb-2">Try a curated link below or drop your own URL to get the vibes going.</p>
</div>
<div className="grid grid-cols-2 gap-3">
{starterPicks.map((pick) => (
<button
key={pick.url}
onClick={() => handleStarterPick(pick.url)}
className="text-left bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl p-4 hover:border-blue-200 hover:shadow-sm dark:hover:border-blue-800 transition-all"
>
<div className="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
<Sparkles className="w-4 h-4 text-blue-500" /> {pick.label}
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">{pick.url}</p>
</button>
))}
</div> </div>
{/* Queue List */} {/* Queue List */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between px-1"> <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> <div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-full p-1">
<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> {([['queue','Queue'], ['history','History']] as const).map(([key,label]) => (
</div> <button
key={key}
<div className="space-y-3"> onClick={() => setActiveList(key)}
{queue.length === 0 ? ( 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'}`}
<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> {label}
</button>
))}
</div> </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">
queue.map(article => ( {activeList === 'queue' ? `${queue.length} queued` : `${history.length} played`}
<div key={article.id} onClick={() => setViewId(article.id)} className="cursor-pointer"> </span>
<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, 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>
{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>
) : (
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> </div>
@@ -628,7 +825,7 @@ export default function App() {
</h4> </h4>
{/* Progress Bar */} {/* Progress Bar */}
<div className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden"> <div className="w-full h-1 bg-slate-200 dark:bg-slate-700 rounded-full mt-2 overflow-hidden">
<div <div
className={`h-full transition-all duration-300 ${isBuffering ? 'bg-blue-400/50 animate-pulse' : 'bg-blue-500'}`} className={`h-full transition-all duration-300 ${isBuffering ? 'bg-blue-400/50 animate-pulse' : 'bg-blue-500'}`}
style={{ width: `${((currentArticle.currentSegmentIndex + 1) / Math.max(1, currentArticle.segments.length)) * 100}%`}} style={{ width: `${((currentArticle.currentSegmentIndex + 1) / Math.max(1, currentArticle.segments.length)) * 100}%`}}
/> />
@@ -636,6 +833,19 @@ export default function App() {
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1"> <p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length} Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
</p> </p>
<div className="flex items-center gap-2 mt-2 justify-center sm:justify-start text-xs text-blue-500 dark:text-blue-300">
<Waves className={`w-4 h-4 ${playerState.isPlaying ? 'animate-pulse' : ''}`} />
<div className="flex items-end gap-1 h-4">
{[0.8, 1.2, 0.9, 1.4].map((height, idx) => (
<span
key={idx}
className={`w-1 rounded-full bg-gradient-to-t from-blue-500 to-indigo-400 ${playerState.isPlaying ? 'animate-[bounce_1.4s_infinite]' : ''}`}
style={{ height: `${height * 12}px`, opacity: isBuffering ? 0.4 : 1 }}
/>
))}
</div>
<span className="text-[11px] text-slate-500 dark:text-slate-400">Vibe visualizer</span>
</div>
</div> </div>
) : ( ) : (
<div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div> <div className="text-slate-400 dark:text-slate-500 text-sm font-medium">Ready to play</div>

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Article, PlaybackStatus } from '../types'; 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 { interface QueueItemProps {
article: Article; article: Article;
@@ -10,15 +10,27 @@ interface QueueItemProps {
onPlay: () => void; onPlay: () => void;
onPause: () => void; onPause: () => void;
onRemove: () => 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> = ({ export const QueueItem: React.FC<QueueItemProps> = ({
article, article,
isActive, isActive,
isPlaying, isPlaying,
onPlay, onPlay,
onPause, onPause,
onRemove onRemove,
onPlayNext,
draggable,
onDragStart,
onDragOver,
onDrop,
onDragEnd
}) => { }) => {
// Check if buffering: active, supposed to be playing, but current segment audio is missing // 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; const isReady = article.status === PlaybackStatus.READY || article.status === PlaybackStatus.PAUSED || article.status === PlaybackStatus.PLAYING || article.status === PlaybackStatus.COMPLETED;
return ( 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 relative group flex items-center p-4 rounded-xl border transition-all duration-200
${isActive ${isActive
? 'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800 shadow-sm' ? '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' : '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"> <div className="flex-shrink-0 mr-4 w-8 flex justify-center">
{getStatusIcon()} {getStatusIcon()}
</div> </div>
@@ -84,7 +107,16 @@ export const QueueItem: React.FC<QueueItemProps> = ({
{isActive && isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />} {isActive && isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</button> </button>
)} )}
<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) => { onClick={(e) => {
e.stopPropagation(); // Prevent article selection when removing e.stopPropagation(); // Prevent article selection when removing
onRemove(); onRemove();

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Article, ReaderSettings } from '../types'; import { Article, ReaderSettings } from '../types';
import { FileText, MousePointerClick } from 'lucide-react'; import { FileText, MousePointerClick, Share2, Quote } from 'lucide-react';
import { getDisplayUrl } from '../utils/url'; import { getDisplayUrl } from '../utils/url';
interface ReaderViewProps { interface ReaderViewProps {
@@ -30,7 +30,10 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
fontSize: 'lg', fontSize: 'lg',
lineHeight: 'relaxed', lineHeight: 'relaxed',
fontFamily: 'serif', fontFamily: 'serif',
autoScroll: true autoScroll: true,
readingTone: 'clean',
pageWidth: 'standard',
zenMode: false
}; };
const getFontClass = () => { const getFontClass = () => {
@@ -59,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) { if (!article) {
return ( 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"> <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">
@@ -72,39 +106,41 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
const displayUrl = getDisplayUrl(article.url); const displayUrl = getDisplayUrl(article.url);
return ( 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={`${getToneClasses()} ${getWidthClass()} rounded-2xl 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"> {!s.zenMode && (
<div className="flex-1 pr-4"> <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">
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight"> <div className="flex-1 pr-4">
{article.title} <h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 leading-tight">
</h2> {article.title}
<a </h2>
href={displayUrl.href} <a
target="_blank" href={displayUrl.href}
rel="noopener noreferrer" target="_blank"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block" rel="noopener noreferrer"
> className="text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2 inline-block"
{displayUrl.hostname} >
</a> {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>
)}
{/* Auto Scroll Toggle */}
<button <div
onClick={onToggleAutoScroll} ref={scrollRef}
title={s.autoScroll ? "Disable auto-scroll" : "Enable auto-scroll"} 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' : ''}`}
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()}`}
> >
{article.segments.length > 0 ? ( {article.segments.length > 0 ? (
article.segments.map((segment, idx) => { article.segments.map((segment, idx) => {
@@ -112,21 +148,38 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
const isBuffering = isActive && article.status === 'PLAYING' && !segment.audioUrl; const isBuffering = isActive && article.status === 'PLAYING' && !segment.audioUrl;
return ( return (
<div <div
key={segment.id} key={segment.id}
id={`segment-${idx}`} id={`segment-${idx}`}
onClick={() => onSegmentSelect?.(idx)} onClick={() => onSegmentSelect?.(idx)}
title="Click to play from here" title="Click to play from here"
className={` 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 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
${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'}`
? `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' : '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> </div>
); );
}) })

View File

@@ -53,4 +53,7 @@ export interface ReaderSettings {
lineHeight: 'normal' | 'relaxed' | 'loose'; lineHeight: 'normal' | 'relaxed' | 'loose';
fontFamily: 'sans' | 'serif' | 'mono'; fontFamily: 'sans' | 'serif' | 'mono';
autoScroll: boolean; autoScroll: boolean;
readingTone: 'clean' | 'sepia' | 'night';
pageWidth: 'cozy' | 'standard' | 'wide';
zenMode: boolean;
} }