feat: scaffold initial RSS reader project with Go backend and React frontend
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user