feat: scaffold initial RSS reader project with Go backend and React frontend

This commit is contained in:
2026-08-24 08:08:31 -07:00
commit 3742dbdce4
44 changed files with 9901 additions and 0 deletions
+327
View File
@@ -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>
);
};