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

322
App.tsx
View File

@@ -1,7 +1,7 @@
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 { 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 { MIN_SPEED, MAX_SPEED, SPEED_STEP } from './constants';
import { extractArticleContent, generateSpeechFromText } from './services/geminiService';
@@ -16,8 +16,11 @@ 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,
@@ -31,9 +34,19 @@ export default function App() {
fontSize: 'lg',
lineHeight: 'relaxed',
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 --
const audioRef = useRef<HTMLAudioElement>(new Audio());
const processingRef = useRef<Set<string>>(new Set());
@@ -148,13 +161,21 @@ export default function App() {
}));
}, [playerState.currentArticleId]);
const handleAddUrl = async () => {
if (!inputUrl.trim()) return;
const enqueueArticle = async (targetUrl: string, options: { autoPlay?: boolean; pinView?: boolean } = {}) => {
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: [],
@@ -163,8 +184,7 @@ export default function App() {
};
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);
@@ -191,7 +211,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;
@@ -208,6 +228,21 @@ export default function App() {
}
};
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 --
const playArticle = useCallback(async (id: string) => {
@@ -248,6 +283,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,
@@ -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 speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
setPlayerState(prev => ({ ...prev, playbackRate: speed }));
@@ -509,6 +581,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">
@@ -527,64 +653,135 @@ 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()}
/>
<div className="flex items-center gap-2 self-end sm:self-auto">
<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>
<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>
{/* 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, 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);
}}
/>
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>
@@ -636,6 +833,19 @@ export default function App() {
<p className="text-xs text-slate-500 dark:text-slate-400 truncate mt-1">
Playing segment {currentArticle.currentSegmentIndex + 1} of {currentArticle.segments.length}
</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 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 { 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

View File

@@ -1,7 +1,7 @@
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 {
@@ -30,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 = () => {
@@ -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) {
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">
@@ -72,39 +106,41 @@ export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onTog
const displayUrl = getDisplayUrl(article.url);
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={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>
<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>
{/* 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) => {
@@ -119,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>
);
})

View File

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