Introduces a visual indicator for when the reader is buffering, meaning it's supposed to be playing but the current audio segment is not yet available. Also, prevents accidental article selection when clicking the "Remove" button in the queue by adding `stopPropagation`.
147 lines
5.6 KiB
TypeScript
147 lines
5.6 KiB
TypeScript
|
|
import React, { useEffect, useRef } from 'react';
|
|
import { Article, ReaderSettings } from '../types';
|
|
import { FileText, MousePointerClick } from 'lucide-react';
|
|
|
|
interface ReaderViewProps {
|
|
article?: Article | null;
|
|
settings?: ReaderSettings;
|
|
onToggleAutoScroll?: () => void;
|
|
onSegmentSelect?: (index: number) => void;
|
|
}
|
|
|
|
export const ReaderView: React.FC<ReaderViewProps> = ({ article, settings, onToggleAutoScroll, onSegmentSelect }) => {
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-scroll to active segment
|
|
useEffect(() => {
|
|
if (!article || article.status !== 'PLAYING' || settings?.autoScroll === false) return;
|
|
|
|
const activeEl = document.getElementById(`segment-${article.currentSegmentIndex}`);
|
|
if (activeEl && scrollRef.current) {
|
|
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
}, [article?.currentSegmentIndex, article?.status, settings?.autoScroll]);
|
|
|
|
// Default settings fallback
|
|
const s = settings || {
|
|
isDarkMode: false,
|
|
fontSize: 'lg',
|
|
lineHeight: 'relaxed',
|
|
fontFamily: 'serif',
|
|
autoScroll: true
|
|
};
|
|
|
|
const getFontClass = () => {
|
|
switch(s.fontFamily) {
|
|
case 'sans': return 'font-sans';
|
|
case 'mono': return 'font-mono';
|
|
default: return 'font-serif';
|
|
}
|
|
};
|
|
|
|
const getSizeClass = () => {
|
|
switch(s.fontSize) {
|
|
case 'sm': return 'text-sm';
|
|
case 'base': return 'text-base';
|
|
case 'xl': return 'text-xl';
|
|
case '2xl': return 'text-2xl';
|
|
default: return 'text-lg';
|
|
}
|
|
};
|
|
|
|
const getLeadingClass = () => {
|
|
switch(s.lineHeight) {
|
|
case 'normal': return 'leading-normal';
|
|
case 'loose': return 'leading-loose';
|
|
default: return 'leading-relaxed';
|
|
}
|
|
};
|
|
|
|
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">
|
|
<FileText className="w-12 h-12 mb-4 opacity-50" />
|
|
<p className="text-lg font-medium">Select an article to read along</p>
|
|
<p className="text-sm">The text will appear here while you listen.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
|
|
{/* 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()}`}
|
|
>
|
|
{article.segments.length > 0 ? (
|
|
article.segments.map((segment, idx) => {
|
|
const isActive = article.currentSegmentIndex === idx;
|
|
const isBuffering = isActive && article.status === 'PLAYING' && !segment.audioUrl;
|
|
|
|
return (
|
|
<div
|
|
key={segment.id}
|
|
id={`segment-${idx}`}
|
|
onClick={() => onSegmentSelect?.(idx)}
|
|
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>
|
|
);
|
|
})
|
|
) : (
|
|
// Loading State skeleton
|
|
<div className="space-y-4 animate-pulse">
|
|
{[1,2,3,4].map(i => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded w-full"></div>
|
|
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded w-full"></div>
|
|
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded w-3/4"></div>
|
|
</div>
|
|
))}
|
|
<p className="text-slate-400 dark:text-slate-600 italic mt-4">Extracting article content...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|