328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
|
import { Sidebar } from './Sidebar';
|
|
import { formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
|
import {
|
|
Star,
|
|
CheckCircle2,
|
|
ExternalLink,
|
|
Share2,
|
|
Clock,
|
|
User as UserIcon,
|
|
CheckCheck,
|
|
} from 'lucide-react';
|
|
|
|
interface ThreePaneViewProps {
|
|
filter: FilterState;
|
|
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
|
categories: Category[];
|
|
feeds: Feed[];
|
|
stats: Stats | null;
|
|
entries: FeedEntry[];
|
|
selectedIndex: number;
|
|
onSelectEntry: (index: number) => void;
|
|
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
onMarkAllRead: () => void;
|
|
sidebarCollapsed: boolean;
|
|
}
|
|
|
|
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|
filter,
|
|
onFilterChange,
|
|
categories,
|
|
feeds,
|
|
stats,
|
|
entries,
|
|
selectedIndex,
|
|
onSelectEntry,
|
|
onToggleRead,
|
|
onToggleStar,
|
|
onMarkAllRead,
|
|
sidebarCollapsed,
|
|
}) => {
|
|
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const readerPaneRef = useRef<HTMLDivElement>(null);
|
|
const endSentinelRef = useRef<HTMLDivElement>(null);
|
|
|
|
const selectedEntry = entries[selectedIndex] || entries[0];
|
|
|
|
useEffect(() => {
|
|
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
|
const el = cardRefs.current[selectedIndex];
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
}
|
|
}, [selectedIndex, entries.length]);
|
|
|
|
// Robust Auto-Read Detection (Dwell Time for short posts + IntersectionObserver for end of post + Scroll Progress)
|
|
useEffect(() => {
|
|
if (!selectedEntry || selectedEntry.is_read) return;
|
|
|
|
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Check if the content fits entirely in the viewport (short article)
|
|
const checkViewportFit = () => {
|
|
if (readerPaneRef.current) {
|
|
const { scrollHeight, clientHeight } = readerPaneRef.current;
|
|
// If article is short (fits or almost fits), mark as read after 1.5s of reading
|
|
if (scrollHeight <= clientHeight + 60) {
|
|
dwellTimer = setTimeout(() => {
|
|
if (selectedEntry && !selectedEntry.is_read) {
|
|
onToggleRead(selectedEntry);
|
|
}
|
|
}, 1500);
|
|
} else {
|
|
// For longer articles, if user stays focused for 5 seconds, also auto-read
|
|
dwellTimer = setTimeout(() => {
|
|
if (selectedEntry && !selectedEntry.is_read) {
|
|
onToggleRead(selectedEntry);
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Small delay to allow DOM render calculation
|
|
const timeout = setTimeout(checkViewportFit, 100);
|
|
|
|
// IntersectionObserver to observe bottom of article
|
|
let observer: IntersectionObserver | null = null;
|
|
if (endSentinelRef.current && readerPaneRef.current) {
|
|
observer = new IntersectionObserver(
|
|
(entriesObs) => {
|
|
entriesObs.forEach((entryObs) => {
|
|
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
|
|
onToggleRead(selectedEntry);
|
|
}
|
|
});
|
|
},
|
|
{
|
|
root: readerPaneRef.current,
|
|
threshold: 0.1,
|
|
}
|
|
);
|
|
observer.observe(endSentinelRef.current);
|
|
}
|
|
|
|
return () => {
|
|
clearTimeout(timeout);
|
|
if (dwellTimer) clearTimeout(dwellTimer);
|
|
if (observer) observer.disconnect();
|
|
};
|
|
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
|
|
|
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
if (!selectedEntry || selectedEntry.is_read) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
|
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
|
if (scrollProgress >= 0.85) {
|
|
onToggleRead(selectedEntry);
|
|
}
|
|
};
|
|
|
|
const getListTitle = () => {
|
|
if (filter.search) return `Search: "${filter.search}"`;
|
|
if (filter.feedId) {
|
|
const f = feeds.find((item) => item.id === filter.feedId);
|
|
return f ? f.title : 'Feed';
|
|
}
|
|
if (filter.categoryId) {
|
|
const c = categories.find((item) => item.id === filter.categoryId);
|
|
return c ? c.title : 'Category';
|
|
}
|
|
if (filter.view === 'starred') return 'Starred';
|
|
if (filter.view === 'all') return 'All Articles';
|
|
return 'Inbox';
|
|
};
|
|
|
|
return (
|
|
<div className="three-pane-container">
|
|
{/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */}
|
|
<div className={`sidebar-pane ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
|
{!sidebarCollapsed && (
|
|
<Sidebar
|
|
filter={filter}
|
|
onFilterChange={onFilterChange}
|
|
categories={categories}
|
|
feeds={feeds}
|
|
stats={stats}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pane 2: Article List (Reeder 4 minimal style) */}
|
|
<div className="article-list-pane">
|
|
<div className="article-list-header">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 700, fontSize: '0.85rem' }}>
|
|
<span>{getListTitle()}</span>
|
|
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 500 }}>
|
|
{entries.length}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
onClick={onMarkAllRead}
|
|
title="Mark all as read (Shift + A)"
|
|
style={{ width: 28, height: 28 }}
|
|
>
|
|
<CheckCheck size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="article-list-scroll-area">
|
|
{entries.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
|
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>✨</div>
|
|
<h3 style={{ fontSize: '0.9rem' }}>Inbox Zero</h3>
|
|
<p style={{ fontSize: '0.75rem' }}>No articles to read here.</p>
|
|
</div>
|
|
) : (
|
|
entries.map((entry, index) => {
|
|
const isSelected = index === selectedIndex;
|
|
const snippet = stripHtml(entry.content || entry.raw_content, 100);
|
|
const relativeTime = formatRelativeDate(entry.published_at);
|
|
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
ref={(el) => (cardRefs.current[index] = el)}
|
|
className={`article-item-card ${isSelected ? 'selected' : ''} ${
|
|
entry.is_read ? 'is-read' : ''
|
|
}`}
|
|
onClick={() => onSelectEntry(index)}
|
|
>
|
|
<div className="article-item-top">
|
|
<span className="article-item-source">{entry.feed_title}</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<span>{relativeTime}</span>
|
|
{!entry.is_read && <div className="unread-dot" />}
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
|
|
|
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pane 3: Distraction-Free Reading Canvas */}
|
|
<div className="reader-pane" ref={readerPaneRef} onScroll={handleReaderScroll}>
|
|
{selectedEntry ? (
|
|
<>
|
|
{/* Top Toolbar */}
|
|
<div className="reader-toolbar">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
<button
|
|
className="btn-icon"
|
|
style={{
|
|
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
|
}}
|
|
onClick={() => onToggleStar(selectedEntry)}
|
|
title={selectedEntry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
|
>
|
|
<Star
|
|
size={16}
|
|
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
|
/>
|
|
</button>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{
|
|
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
|
}}
|
|
onClick={() => onToggleRead(selectedEntry)}
|
|
title={selectedEntry.is_read ? 'Mark Unread (M)' : 'Mark Read (M)'}
|
|
>
|
|
<CheckCircle2 size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<a
|
|
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="btn-secondary"
|
|
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
|
>
|
|
<Share2 size={13} />
|
|
<span>Share</span>
|
|
</a>
|
|
|
|
<a
|
|
href={selectedEntry.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="btn-secondary"
|
|
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
|
>
|
|
<ExternalLink size={13} />
|
|
<span>Original Link (V)</span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reading Content */}
|
|
<div className="reader-content-canvas">
|
|
<div className="reader-source-bar">
|
|
<span className="tag-badge">
|
|
{selectedEntry.feed_title}
|
|
</span>
|
|
<span>•</span>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<Clock size={12} />{' '}
|
|
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})}
|
|
</span>
|
|
<span>•</span>
|
|
<span>{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
|
{selectedEntry.author && (
|
|
<>
|
|
<span>•</span>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<UserIcon size={12} /> {selectedEntry.author}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<h1 className="reader-headline">
|
|
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
|
{selectedEntry.title}
|
|
</a>
|
|
</h1>
|
|
|
|
<div
|
|
className="reader-body"
|
|
dangerouslySetInnerHTML={{
|
|
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
|
}}
|
|
/>
|
|
|
|
{/* End of article sentinel for auto-read observer */}
|
|
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="empty-state">
|
|
<div className="empty-state-icon">📖</div>
|
|
<h3>No Article Selected</h3>
|
|
<p>Select an article from the list or press 'j' / 'k' to read.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|