refactor: implement new UI/UX architecture with redesigned desktop and mobile layouts, CSS-in-CSS modularization, and updated components.

This commit is contained in:
2026-08-29 00:53:57 -07:00
parent dfbf646332
commit cd98c0120e
47 changed files with 8039 additions and 7236 deletions
-148
View File
@@ -1,148 +0,0 @@
import React, { useState } from 'react';
import { Category, Feed } from '../types';
import { api } from '../services/api';
import { X, Plus, Loader2 } from 'lucide-react';
interface AddFeedModalProps {
categories: Category[];
onClose: () => void;
onFeedAdded: (feed: Feed) => void;
}
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
categories,
onClose,
onFeedAdded,
}) => {
const [feedUrl, setFeedUrl] = useState('');
const [title, setTitle] = useState('');
const [categoryId, setCategoryId] = useState<string>('');
const [newCatTitle, setNewCatTitle] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!feedUrl.trim()) return;
setLoading(true);
setError(null);
try {
let finalCatId: string | undefined = categoryId || undefined;
// If user typed a new category name
if (newCatTitle.trim()) {
const newCat = await api.createCategory(newCatTitle.trim());
finalCatId = newCat.id;
}
const newFeed = await api.createFeed(
feedUrl.trim(),
finalCatId,
title.trim() || undefined
);
onFeedAdded(newFeed);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to add feed');
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Add New Subscription</h3>
<button className="btn-icon" onClick={onClose}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="modal-body">
{error && <div className="alert-danger">{error}</div>}
<div className="form-group">
<label className="form-label">Feed or Website URL *</label>
<input
type="url"
className="form-input"
value={feedUrl}
onChange={(e) => setFeedUrl(e.target.value)}
placeholder="https://news.ycombinator.com/rss"
required
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label">Custom Title (Optional)</label>
<input
type="text"
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Leave blank to auto-detect from feed"
/>
</div>
<div className="form-group">
<label className="form-label">Category / Folder</label>
<select
className="form-select"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value="">(No Category)</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">Or Create New Category</label>
<input
type="text"
className="form-input"
value={newCatTitle}
onChange={(e) => setNewCatTitle(e.target.value)}
placeholder="e.g. AI & Tech"
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.5rem' }}>
<button
type="button"
className="btn-secondary"
onClick={onClose}
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={loading || !feedUrl.trim()}
>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" /> Verifying...
</>
) : (
<>
<Plus size={16} /> Subscribe
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
-152
View File
@@ -1,152 +0,0 @@
import React from 'react';
import { FeedEntry } from '../types';
import { estimateReadingTime } from '../utils';
import {
X,
Star,
CheckCircle2,
ExternalLink,
Share2,
Clock,
User as UserIcon,
} from 'lucide-react';
interface ArticleModalProps {
entry: FeedEntry;
onClose: () => void;
onToggleStar: (e?: React.MouseEvent) => void;
onToggleRead: (e?: React.MouseEvent) => void;
}
export const ArticleModal: React.FC<ArticleModalProps> = ({
entry,
onClose,
onToggleStar,
onToggleRead,
}) => {
const shareUrl = `${window.location.origin}/share/${entry.share_uuid || entry.id}.html`;
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (entry.is_read) return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight <= clientHeight + 40) return;
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
if (scrollProgress >= 0.9) {
onToggleRead();
}
};
return (
<div className="article-drawer-overlay" onClick={onClose}>
<div
className="article-drawer-card"
onClick={(e) => e.stopPropagation()}
>
{/* Top Header Actions */}
<div className="article-drawer-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<button
className="btn-icon"
style={{
color: entry.is_starred ? 'var(--star)' : undefined,
}}
onClick={onToggleStar}
title={entry.is_starred ? 'Unstar (S)' : 'Star (S)'}
>
<Star
size={17}
fill={entry.is_starred ? 'var(--star)' : 'none'}
/>
</button>
<button
className="btn-icon"
style={{
color: entry.is_read ? 'var(--success)' : undefined,
}}
onClick={onToggleRead}
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
>
<CheckCircle2 size={17} />
</button>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginLeft: '0.5rem' }}>
{entry.feed_title}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<a
href={shareUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
>
<Share2 size={13} />
<span>Share</span>
</a>
<a
href={entry.url}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
>
<ExternalLink size={13} />
<span>Original Link</span>
</a>
<button className="btn-icon" onClick={onClose}>
<X size={18} />
</button>
</div>
</div>
{/* Article Scroll Body */}
<div className="article-drawer-scroll" onScroll={handleScroll}>
<div className="reader-canvas" style={{ padding: 0 }}>
<div className="reader-source-bar">
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
{entry.feed_title}
</span>
<span>•</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={13} />{' '}
{new Date(entry.published_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<span>•</span>
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
{entry.author && (
<>
<span>•</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<UserIcon size={13} /> {entry.author}
</span>
</>
)}
</div>
<h1 className="reader-headline">
<a href={entry.url} target="_blank" rel="noopener noreferrer">
{entry.title}
</a>
</h1>
<div
className="reader-body"
dangerouslySetInnerHTML={{
__html: entry.content || entry.raw_content || '',
}}
/>
</div>
</div>
</div>
</div>
);
};
-216
View File
@@ -1,216 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { FeedEntry } from '../types';
import {
Star,
CheckCircle2,
ExternalLink,
Share2,
Clock,
User as UserIcon,
} from 'lucide-react';
interface CompactListViewProps {
entries: FeedEntry[];
selectedIndex: number;
onSelectEntry: (index: number) => void;
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
}
export const CompactListView: React.FC<CompactListViewProps> = ({
entries,
selectedIndex,
onSelectEntry,
onToggleRead,
onToggleStar,
}) => {
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
if (selectedIndex >= 0 && selectedIndex < entries.length) {
const el = rowRefs.current[selectedIndex];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, [selectedIndex, entries.length]);
if (entries.length === 0) {
return (
<div className="empty-state">
<div className="empty-state-icon">🎉</div>
<h3>Inbox Zero!</h3>
<p>No articles to display in this view.</p>
</div>
);
}
const selectedEntry = entries[selectedIndex] || entries[0];
return (
<div className="compact-layout">
{/* Left List Pane (Gmail style) */}
<div className="compact-list">
{entries.map((entry, index) => {
const isSelected = index === selectedIndex;
const pubDate = new Date(entry.published_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
// Strip HTML tags for clean 1-line snippet
const snippet = (entry.content || entry.raw_content || '')
.replace(/<[^>]*>?/gm, '')
.substring(0, 120);
return (
<div
key={entry.id}
ref={(el) => (rowRefs.current[index] = el)}
className={`compact-row ${isSelected ? 'focused' : ''} ${
entry.is_read ? 'is-read' : ''
}`}
onClick={() => onSelectEntry(index)}
>
{/* Star toggle */}
<button
className="btn-icon"
style={{
width: 24,
height: 24,
color: entry.is_starred ? 'var(--star)' : 'var(--text-muted)',
}}
onClick={(e) => onToggleStar(entry, e)}
title={entry.is_starred ? 'Unstar' : 'Star'}
>
<Star
size={15}
fill={entry.is_starred ? 'var(--star)' : 'none'}
/>
</button>
{/* Mark read toggle */}
<button
className="btn-icon"
style={{
width: 24,
height: 24,
color: entry.is_read ? 'var(--success)' : 'var(--unread-badge)',
}}
onClick={(e) => onToggleRead(entry, e)}
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
>
<CheckCircle2 size={15} />
</button>
{/* Feed Title */}
<div className="compact-row-feed" title={entry.feed_title}>
{entry.feed_title}
</div>
{/* Article Title & Snippet */}
<div className="compact-row-main">
<span className="compact-row-title">{entry.title}</span>
<span className="compact-row-snippet"> - {snippet}</span>
</div>
{/* Date */}
<div className="compact-row-date">{pubDate}</div>
</div>
);
})}
</div>
{/* Right Reading Pane */}
{selectedEntry && (
<div className="compact-reading-pane">
<div className="reading-pane-header">
<div className="stream-source-meta">
<span className="stream-source-badge">{selectedEntry.feed_title}</span>
<span>•</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Clock size={12} />{' '}
{new Date(selectedEntry.published_at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{selectedEntry.author && (
<>
<span>•</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<UserIcon size={12} /> {selectedEntry.author}
</span>
</>
)}
</div>
<h1 className="reading-pane-title">
<a
href={selectedEntry.url}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'inherit', textDecoration: 'none' }}
>
{selectedEntry.title} ↗
</a>
</h1>
{/* Actions Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginTop: '1rem' }}>
<button
className="btn-secondary"
onClick={() => onToggleRead(selectedEntry)}
>
<CheckCircle2 size={15} />
<span>{selectedEntry.is_read ? 'Mark as Unread (M)' : 'Mark as Read (M)'}</span>
</button>
<button
className="btn-secondary"
style={{ color: selectedEntry.is_starred ? 'var(--star)' : undefined }}
onClick={() => onToggleStar(selectedEntry)}
>
<Star
size={15}
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
/>
<span>{selectedEntry.is_starred ? 'Starred (S)' : 'Star (S)'}</span>
</button>
<a
href={selectedEntry.url}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary"
>
<ExternalLink size={15} />
<span>Open Original (V)</span>
</a>
<a
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary"
>
<Share2 size={15} />
<span>Public Share</span>
</a>
</div>
</div>
<div
className="reading-pane-body"
dangerouslySetInnerHTML={{
__html: selectedEntry.content || selectedEntry.raw_content || '',
}}
/>
</div>
)}
</div>
);
};
-274
View File
@@ -1,274 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import { ViewMode } from '../types';
import {
Rss,
Search,
Columns3,
LayoutGrid,
Settings,
RefreshCw,
Plus,
HelpCircle,
LogOut,
PanelLeft,
PanelLeftClose,
MoreVertical,
} from 'lucide-react';
interface HeaderProps {
search: string;
onSearchChange: (val: string) => void;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
isRefreshing: boolean;
onRefreshAll: () => void;
onOpenAddFeed: () => void;
onOpenShortcuts: () => void;
onLogout: () => void;
sidebarCollapsed: boolean;
onToggleSidebar: () => void;
}
export const Header: React.FC<HeaderProps> = ({
search,
onSearchChange,
viewMode,
onViewModeChange,
isRefreshing,
onRefreshAll,
onOpenAddFeed,
onOpenShortcuts,
onLogout,
sidebarCollapsed,
onToggleSidebar,
}) => {
const searchInputRef = useRef<HTMLInputElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key === '/' &&
document.activeElement?.tagName !== 'INPUT' &&
document.activeElement?.tagName !== 'TEXTAREA'
) {
e.preventDefault();
searchInputRef.current?.focus();
searchInputRef.current?.select();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<header className="app-header">
{/* Left: Logo & Sidebar Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
{!isMobile && viewMode === 'split' && (
<button
className="btn-icon"
onClick={onToggleSidebar}
title={sidebarCollapsed ? 'Show Sidebar ( [ )' : 'Hide Sidebar ( [ )'}
>
{sidebarCollapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
</button>
)}
<div
className="logo-area"
onClick={() => onViewModeChange(isMobile ? 'waterfall' : 'split')}
>
<div style={{ color: 'var(--accent)', display: 'flex', alignItems: 'center' }}>
<Rss size={19} />
</div>
<span>RSSPipe</span>
{!isMobile && <span className="logo-badge">Gateway</span>}
</div>
</div>
{/* Center: Search Bar */}
<div className="search-bar-container">
<Search size={14} className="search-icon" />
<input
ref={searchInputRef}
type="text"
className="search-input"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={isMobile ? 'Search...' : "Search feeds, 'is:unread'..."}
/>
{!search && !isMobile && <span className="search-shortcut-hint">/</span>}
</div>
{/* Right: Actions */}
{isMobile ? (
// Mobile Header Right: 3 Dots Menu Button
<div style={{ position: 'relative' }}>
<button
className="btn-icon"
onClick={() => setMobileMenuOpen((prev) => !prev)}
title="Menu"
>
<MoreVertical size={18} />
</button>
{mobileMenuOpen && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 40 }}
onClick={() => setMobileMenuOpen(false)}
/>
<div className="mobile-menu-dropdown">
<button
className="mobile-menu-item"
onClick={() => {
setMobileMenuOpen(false);
onOpenAddFeed();
}}
>
<Plus size={15} />
<span>Add Feed</span>
</button>
<button
className="mobile-menu-item"
onClick={() => {
setMobileMenuOpen(false);
onRefreshAll();
}}
>
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
<span>Refresh Feeds</span>
</button>
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
<button
className="mobile-menu-item"
onClick={() => {
setMobileMenuOpen(false);
onViewModeChange('waterfall');
}}
>
<LayoutGrid size={15} />
<span>Waterfall Feed</span>
</button>
<button
className="mobile-menu-item"
onClick={() => {
setMobileMenuOpen(false);
onViewModeChange('split');
}}
>
<Columns3 size={15} />
<span>3-Split View</span>
</button>
<button
className="mobile-menu-item"
onClick={() => {
setMobileMenuOpen(false);
onViewModeChange('settings');
}}
>
<Settings size={15} />
<span>Settings</span>
</button>
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
<button
className="mobile-menu-item"
style={{ color: 'var(--danger)' }}
onClick={() => {
setMobileMenuOpen(false);
onLogout();
}}
>
<LogOut size={15} />
<span>Sign Out</span>
</button>
</div>
</>
)}
</div>
) : (
// Desktop Header Right Actions
<div className="header-actions">
<button
className="btn-primary"
onClick={onOpenAddFeed}
title="Add new subscription (A)"
>
<Plus size={14} />
<span>Add Feed</span>
</button>
<button
className="btn-icon"
onClick={onRefreshAll}
title="Refresh all feeds (R)"
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
</button>
<div className="view-mode-pill">
<button
className={`btn-icon ${viewMode === 'split' ? 'active' : ''}`}
style={{ width: 28, height: 28 }}
onClick={() => onViewModeChange('split')}
title="Desktop 3-Pane Split View (1)"
>
<Columns3 size={14} />
</button>
<button
className={`btn-icon ${viewMode === 'waterfall' ? 'active' : ''}`}
style={{ width: 28, height: 28 }}
onClick={() => onViewModeChange('waterfall')}
title="Xiaohongshu Waterfall View (2)"
>
<LayoutGrid size={14} />
</button>
<button
className={`btn-icon ${viewMode === 'settings' ? 'active' : ''}`}
style={{ width: 28, height: 28 }}
onClick={() => onViewModeChange('settings')}
title="Settings & Subscriptions (3)"
>
<Settings size={14} />
</button>
</div>
<button
className="btn-icon"
onClick={onOpenShortcuts}
title="Keyboard shortcuts (?)"
>
<HelpCircle size={15} />
</button>
<button
className="btn-icon"
onClick={onLogout}
title="Sign Out"
style={{ color: 'var(--danger)' }}
>
<LogOut size={15} />
</button>
</div>
)}
</header>
);
};
@@ -1,93 +0,0 @@
import React from 'react';
import { X, Keyboard } from 'lucide-react';
interface KeyboardShortcutsModalProps {
onClose: () => void;
}
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
onClose,
}) => {
const shortcuts = [
{ key: 'j / k', desc: 'Navigate to Next / Previous article' },
{ key: 'o / Enter', desc: 'Open / Expand selected article' },
{ key: 'v', desc: 'Open original link in a new browser tab' },
{ key: 's', desc: 'Star / Unstar article' },
{ key: 'm', desc: 'Toggle Read / Unread status' },
{ key: 'e / Shift+I', desc: 'Archive / Mark Read and advance to next' },
{ key: 'Shift + A', desc: 'Mark all items in current view as read' },
{ key: '/', desc: 'Focus search bar' },
{ key: 'r', desc: 'Refresh all feeds' },
{ key: '1', desc: 'Switch to Reeder 4 Minimal 3-Pane Split View' },
{ key: '2', desc: 'Switch to Xiaohongshu Waterfall / Masonry View' },
{ key: '3', desc: 'Switch to Continuous Stream View' },
{ key: 'a', desc: 'Add new subscription modal' },
{ key: '?', desc: 'Open keyboard shortcuts cheatsheet' },
];
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Keyboard size={20} style={{ color: 'var(--accent)' }} />
<h3>Google Reader & Gmail Keyboard Shortcuts</h3>
</div>
<button className="btn-icon" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
SHORTCUT
</th>
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
ACTION
</th>
</tr>
</thead>
<tbody>
{shortcuts.map((sc, i) => (
<tr
key={i}
style={{
borderBottom: '1px solid var(--border-light)',
fontSize: '0.875rem',
}}
>
<td style={{ padding: '0.65rem 0.75rem' }}>
<kbd
style={{
background: 'var(--bg-main)',
border: '1px solid var(--border)',
padding: '3px 8px',
borderRadius: '4px',
fontFamily: 'var(--font-mono)',
fontSize: '0.8rem',
fontWeight: 600,
color: 'var(--accent)',
}}
>
{sc.key}
</kbd>
</td>
<td style={{ padding: '0.65rem 0.75rem', color: 'var(--text-secondary)' }}>
{sc.desc}
</td>
</tr>
))}
</tbody>
</table>
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Press <kbd style={{ padding: '2px 5px', border: '1px solid var(--border)', borderRadius: 3 }}>ESC</kbd> to close any modal.
</div>
</div>
</div>
</div>
);
};
-96
View File
@@ -1,96 +0,0 @@
import React, { useState } from 'react';
import { api } from '../services/api';
import { User } from '../types';
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2 } from 'lucide-react';
interface LoginViewProps {
onLoginSuccess: (user: User) => void;
}
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('admin123456');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await api.login(username, password);
onLoginSuccess(res.user);
} catch (err: any) {
setError(err.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<div className="login-card">
<div className="login-logo">
<div style={{ width: 36, height: 36, color: 'var(--accent)' }}>
<Rss size={36} />
</div>
<span>RSSPipe</span>
</div>
<p className="login-subtitle">RSS-Driven Information Gateway</p>
{error && <div className="alert-danger">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UserIcon size={14} /> Username
</label>
<input
type="text"
className="form-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
required
/>
</div>
<div className="form-group">
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Lock size={14} /> Password
</label>
<input
type="password"
className="form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
className="btn-primary"
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', marginTop: '1rem', fontSize: '0.95rem' }}
disabled={loading}
>
{loading ? (
<>
<Loader2 size={18} className="animate-spin" /> Signing in...
</>
) : (
<>
Sign In <ArrowRight size={18} />
</>
)}
</button>
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Default single-user credentials: <code>admin</code> / <code>admin123456</code>
</div>
</form>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
-246
View File
@@ -1,246 +0,0 @@
import React, { useState } from 'react';
import { Category, Feed, FilterState, Stats } from '../types';
import {
Inbox,
Star,
Layers,
Folder,
FolderOpen,
Rss,
ChevronRight,
ChevronDown,
} from 'lucide-react';
interface SidebarProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
feeds: Feed[];
stats: Stats | null;
}
export const Sidebar: React.FC<SidebarProps> = ({
filter,
onFilterChange,
categories,
feeds,
stats,
}) => {
const [collapsedCats, setCollapsedCats] = useState<Record<string, boolean>>({});
const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => {
e.stopPropagation();
setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] }));
};
// Group feeds by category
const categorizedFeeds: Record<string, Feed[]> = {};
const uncategorizedFeeds: Feed[] = [];
feeds.forEach((f) => {
if (f.category_id) {
if (!categorizedFeeds[f.category_id]) {
categorizedFeeds[f.category_id] = [];
}
categorizedFeeds[f.category_id].push(f);
} else {
uncategorizedFeeds.push(f);
}
});
return (
<div className="sidebar-scroll-area">
{/* Core Navigation Items */}
<div
className={`sidebar-nav-item ${
filter.view === 'inbox' && !filter.feedId && !filter.categoryId
? 'active'
: ''
}`}
onClick={() =>
onFilterChange({
view: 'inbox',
feedId: undefined,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Inbox size={16} />
<span>Inbox</span>
</div>
{stats && stats.total_unread > 0 && (
<span className="badge unread">{stats.total_unread}</span>
)}
</div>
<div
className={`sidebar-nav-item ${
filter.view === 'starred' && !filter.feedId && !filter.categoryId
? 'active'
: ''
}`}
onClick={() =>
onFilterChange({
view: 'starred',
feedId: undefined,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Star size={16} style={{ color: 'var(--star)' }} />
<span>Starred</span>
</div>
{stats && stats.total_starred > 0 && (
<span className="badge">{stats.total_starred}</span>
)}
</div>
<div
className={`sidebar-nav-item ${
filter.view === 'all' && !filter.feedId && !filter.categoryId
? 'active'
: ''
}`}
onClick={() =>
onFilterChange({
view: 'all',
feedId: undefined,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Layers size={16} />
<span>All Articles</span>
</div>
</div>
{/* Categories Section */}
{categories.length > 0 && (
<div style={{ marginTop: '0.6rem' }}>
<div className="sidebar-section-header">
<span>Categories</span>
</div>
{categories.map((cat) => {
const catFeeds = categorizedFeeds[cat.id] || [];
const isCollapsed = collapsedCats[cat.id];
const isCatActive = filter.categoryId === cat.id && !filter.feedId;
const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0);
return (
<div key={cat.id}>
<div
className={`sidebar-nav-item ${isCatActive ? 'active' : ''}`}
onClick={() =>
onFilterChange({
categoryId: cat.id,
feedId: undefined,
})
}
>
<div className="sidebar-nav-label">
<button
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: 0,
marginRight: -3,
}}
onClick={(e) => toggleCategoryCollapse(cat.id, e)}
>
{isCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
</button>
{isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />}
<span>{cat.title}</span>
</div>
{catUnread > 0 && <span className="badge unread">{catUnread}</span>}
</div>
{/* Category feeds */}
{!isCollapsed &&
catFeeds.map((feed) => {
const isFeedActive = filter.feedId === feed.id;
return (
<div
key={feed.id}
className={`feed-item ${isFeedActive ? 'active' : ''}`}
onClick={() =>
onFilterChange({
feedId: feed.id,
categoryId: undefined,
})
}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
overflow: 'hidden',
}}
>
<Rss size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span className="feed-title-text" title={feed.title}>
{feed.title}
</span>
</div>
{feed.unread_count > 0 && (
<span className="badge unread">{feed.unread_count}</span>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
{/* Uncategorized Feeds */}
{uncategorizedFeeds.length > 0 && (
<div style={{ marginTop: '0.6rem' }}>
<div className="sidebar-section-header">
<span>Feeds</span>
</div>
{uncategorizedFeeds.map((feed) => {
const isFeedActive = filter.feedId === feed.id;
return (
<div
key={feed.id}
className={`sidebar-nav-item ${isFeedActive ? 'active' : ''}`}
style={{ paddingLeft: '0.65rem' }}
onClick={() =>
onFilterChange({
feedId: feed.id,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Rss size={14} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span className="feed-title-text" title={feed.title}>
{feed.title}
</span>
</div>
{feed.unread_count > 0 && (
<span className="badge unread">{feed.unread_count}</span>
)}
</div>
);
})}
</div>
)}
</div>
);
};
-569
View File
@@ -1,569 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
import { Sidebar } from './Sidebar';
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
import { api } from '../services/api';
import {
Star,
CheckCircle2,
ExternalLink,
Share2,
Clock,
User as UserIcon,
CheckCheck,
FileText,
Zap,
Sparkles,
Volume2,
Loader2,
Copy,
} from 'lucide-react';
interface ThreePaneViewProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
feeds: Feed[];
stats: Stats | null;
entries: FeedEntry[];
selectedIndex: number;
activeArticle?: FeedEntry | null;
onSelectEntry: (index: number) => void;
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
onMarkAllRead: () => void;
onUpdateEntry?: (entry: FeedEntry) => void;
sidebarCollapsed: boolean;
}
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
filter,
onFilterChange,
categories,
feeds,
stats,
entries,
selectedIndex,
activeArticle,
onSelectEntry,
onToggleRead,
onToggleStar,
onMarkAllRead,
onUpdateEntry,
sidebarCollapsed,
}) => {
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const readerPaneRef = useRef<HTMLDivElement>(null);
const summaryCardRef = useRef<HTMLDivElement>(null);
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
const [showAISummary, setShowAISummary] = useState<boolean>(true);
const [isScraping, setIsScraping] = useState<boolean>(false);
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
// Auto-scroll list to active card
useEffect(() => {
if (selectedIndex >= 0 && selectedIndex < entries.length) {
const el = cardRefs.current[selectedIndex];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}, [selectedIndex, entries.length]);
// Reset variant selection when article changes
useEffect(() => {
if (selectedEntry?.variants?.['readability_full_text']) {
// If full text is available, you can choose to stay on current or default to original
}
}, [selectedEntry?.id]);
// When switching selected article, reset reader scroll position to top and handle 2s timer for short content
useEffect(() => {
if (readerPaneRef.current) {
readerPaneRef.current.scrollTop = 0;
}
if (!selectedEntry || selectedEntry.is_read) return;
const autoReadBottom = localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
if (!autoReadBottom) return;
let timer: ReturnType<typeof setTimeout> | null = null;
// Small delay to allow DOM layout calculation
const timeout = setTimeout(() => {
if (readerPaneRef.current) {
const { scrollHeight, clientHeight } = readerPaneRef.current;
// Condition 2: If content is < 1 page (not scrollable, already at bottom), mark as read after 2 seconds
if (scrollHeight <= clientHeight + 35) {
timer = setTimeout(() => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
}, 2000);
}
}
}, 100);
return () => {
clearTimeout(timeout);
if (timer) clearTimeout(timer);
};
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
// Reading Detection 1: Any active scroll on content >= 1 page immediately marks as read
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (!selectedEntry || selectedEntry.is_read) return;
const { scrollTop } = e.currentTarget;
if (scrollTop > 0) {
onToggleRead(selectedEntry);
}
};
// Reading Detection 2: Clicking anywhere inside the content canvas marks as read
const handleContentPaneClick = () => {
if (selectedEntry && !selectedEntry.is_read) {
onToggleRead(selectedEntry);
}
};
const handleScrapeFullText = async () => {
if (!selectedEntry || isScraping) return;
setIsScraping(true);
try {
const updated = await api.scrapeEntry(selectedEntry.id);
if (onUpdateEntry) {
onUpdateEntry(updated);
}
setSelectedVariantKey('readability_full_text');
} catch (err: any) {
alert(`Scraping failed: ${err.message || err}`);
} finally {
setIsScraping(false);
}
};
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';
};
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
const getRenderedContent = () => {
if (!selectedEntry) return '';
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
return selectedEntry.variants['readability_full_text'].text_value || '';
}
return selectedEntry.content || selectedEntry.raw_content || '';
};
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>}
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
{getEntryTags(entry.variants).map((tag) => (
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
))}
{entry.variants?.['ai_summary'] && (
<span
className="variant-tag-chip"
style={{
background: 'rgba(16, 185, 129, 0.08)',
color: '#34d399',
borderColor: 'rgba(16, 185, 129, 0.25)',
}}
>
✨ AI Summary
</span>
)}
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* Pane 3: Distraction-Free Reading Canvas */}
<div
className="reader-pane"
ref={readerPaneRef}
onScroll={handleReaderScroll}
onClick={handleContentPaneClick}
tabIndex={0}
>
{selectedEntry ? (
<>
{/* Top Toolbar */}
<div className="reader-toolbar" onClick={(e) => e.stopPropagation()}>
<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>
{/* Sliding Ball Capsule Toggle Button */}
<button
type="button"
className={`capsule-switch-btn ${
selectedVariantKey === 'readability_full_text' ? 'active' : ''
} ${isScraping ? 'scraping' : ''}`}
onClick={() => {
if (isScraping) return;
if (selectedVariantKey === 'readability_full_text') {
setSelectedVariantKey('original');
} else {
if (hasReadability) {
setSelectedVariantKey('readability_full_text');
} else {
handleScrapeFullText();
}
}
}}
title={
selectedVariantKey === 'readability_full_text'
? 'Switch to Original RSS content'
: hasReadability
? 'Switch to Clean Full Text'
: 'Scrape and view Clean Full Text'
}
>
<div className="capsule-switch-track">
<span className="capsule-switch-label">
{isScraping
? 'Scraping...'
: selectedVariantKey === 'readability_full_text'
? 'Full Text'
: 'Original'}
</span>
<div className="capsule-switch-thumb">
{isScraping ? (
<Loader2 size={12} className="spin" />
) : selectedVariantKey === 'readability_full_text' ? (
<Zap size={12} />
) : (
<FileText size={12} />
)}
</div>
</div>
</button>
{hasAISummary && (
<button
type="button"
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
style={{
border: '1px solid',
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
borderRadius: '16px',
padding: '3px 9px',
fontSize: '0.72rem',
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
color: showAISummary ? '#34d399' : 'var(--text-muted)',
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
marginLeft: '0.4rem',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
onClick={() => {
const next = !showAISummary;
setShowAISummary(next);
if (next) {
setTimeout(() => {
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
}}
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
>
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
<span>Summary</span>
</button>
)}
{hasTTS && (
<button
type="button"
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
style={{
border: 'none',
borderRadius: '16px',
padding: '3px 9px',
fontSize: '0.72rem',
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
marginLeft: '0.4rem',
}}
onClick={() => setSelectedVariantKey('tts')}
title="Audio Stream"
>
<Volume2 size={12} style={{ marginRight: 4 }} />
Audio
</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="article-header">
<div className="article-header-meta">
<span className="article-feed-badge">
{selectedEntry.feed_title}
</span>
<span className="article-meta-divider">•</span>
<span className="article-meta-item">
<Clock size={12} />
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<span className="article-meta-divider">•</span>
<span className="article-meta-item">{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
{selectedEntry.author && (
<>
<span className="article-meta-divider">•</span>
<span className="article-meta-item">
<UserIcon size={12} /> {selectedEntry.author}
</span>
</>
)}
</div>
<h1 className="reader-headline">
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
{selectedEntry.title}
</a>
</h1>
{getEntryTags(selectedEntry.variants).length > 0 && (
<div className="article-tags-row">
{getEntryTags(selectedEntry.variants).map((tag) => (
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
))}
</div>
)}
</div>
{/* Audio player if TTS variant is selected */}
{selectedVariantKey === 'tts' && (
<div style={{ margin: '1rem 0', padding: '1rem', background: 'var(--bg-tertiary)', borderRadius: '8px' }}>
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>🎧 Audio Stream:</p>
<audio
controls
style={{ width: '100%' }}
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
/>
</div>
)}
{/* AI Summary Card if present and visible */}
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
<div
ref={summaryCardRef}
style={{
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.22)',
borderRadius: 10,
padding: '1rem 1.15rem',
marginBottom: '1.5rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
<Sparkles size={15} />
<span>AI Summary & Takeaways</span>
{selectedEntry.variants.ai_summary.metadata?.model && (
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
{String(selectedEntry.variants.ai_summary.metadata.model)}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
type="button"
className="btn-icon"
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
title="Copy Summary"
onClick={() => {
if (selectedEntry.variants?.ai_summary?.text_value) {
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
}
}}
>
<Copy size={13} />
</button>
<button
type="button"
className="btn-icon"
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
title="Hide Summary"
onClick={() => setShowAISummary(false)}
>
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
</button>
</div>
</div>
<div
style={{
fontSize: '0.88rem',
lineHeight: 1.6,
color: 'var(--text-secondary)',
}}
dangerouslySetInnerHTML={{
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
}}
/>
</div>
)}
<div
className="reader-body"
dangerouslySetInnerHTML={{
__html: getRenderedContent(),
}}
/>
</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>
);
};
-575
View File
@@ -1,575 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { api } from '../services/api';
import { Category, FeedEntry, FilterState } from '../types';
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
import {
Star,
Inbox,
Star as StarIcon,
Layers,
Rss,
ArrowLeft,
Share2,
ExternalLink,
CheckCircle2,
Clock,
User as UserIcon,
FileText,
Zap,
Loader2,
Sparkles,
Volume2,
Copy,
} from 'lucide-react';
interface WaterfallViewProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
entries: FeedEntry[];
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
onUpdateEntry?: (entry: FeedEntry) => void;
}
export const WaterfallView: React.FC<WaterfallViewProps> = ({
filter,
onFilterChange,
categories,
entries,
onToggleRead,
onToggleStar,
onUpdateEntry,
}) => {
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
const [showAISummary, setShowAISummary] = useState<boolean>(true);
const [isScraping, setIsScraping] = useState<boolean>(false);
const readerScrollRef = useRef<HTMLDivElement>(null);
const summaryCardRef = useRef<HTMLDivElement>(null);
// Reset variant selection when selected article changes
useEffect(() => {
setSelectedVariantKey('original');
}, [selectedEntry?.id]);
const handleScrapeFullText = async () => {
if (!selectedEntry || isScraping) return;
setIsScraping(true);
try {
const updated = await api.scrapeEntry(selectedEntry.id);
setSelectedEntry(updated);
onUpdateEntry?.(updated);
setSelectedVariantKey('readability_full_text');
} catch (err: any) {
alert(`Scraping failed: ${err.message || err}`);
} finally {
setIsScraping(false);
}
};
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
const getRenderedContent = () => {
if (!selectedEntry) return '';
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
return selectedEntry.variants['readability_full_text'].text_value || '';
}
return selectedEntry.content || selectedEntry.raw_content || '';
};
// When card is clicked, open in full-screen reader view and immediately mark as read
const handleCardClick = (entry: FeedEntry) => {
setSelectedEntry(entry);
if (!entry.is_read) {
onToggleRead(entry);
}
};
return (
<div className="xhs-waterfall-container">
{/* Top Category Tabs (Xiaohongshu App Style - No Left Sidebar) */}
<div className="xhs-tabs-bar">
<button
className={`xhs-tab-item ${
filter.view === 'inbox' && !filter.categoryId ? 'active' : ''
}`}
onClick={() =>
onFilterChange({
view: 'inbox',
categoryId: undefined,
feedId: undefined,
})
}
>
<Inbox size={13} />
<span>Inbox</span>
</button>
<button
className={`xhs-tab-item ${
filter.view === 'starred' && !filter.categoryId ? 'active' : ''
}`}
onClick={() =>
onFilterChange({
view: 'starred',
categoryId: undefined,
feedId: undefined,
})
}
>
<StarIcon size={13} />
<span>Starred</span>
</button>
<button
className={`xhs-tab-item ${
filter.view === 'all' && !filter.categoryId ? 'active' : ''
}`}
onClick={() =>
onFilterChange({
view: 'all',
categoryId: undefined,
feedId: undefined,
})
}
>
<Layers size={13} />
<span>All</span>
</button>
{/* Categories as Horizontal Chips */}
{categories.map((cat) => {
const isActive = filter.categoryId === cat.id;
return (
<button
key={cat.id}
className={`xhs-tab-item ${isActive ? 'active' : ''}`}
onClick={() =>
onFilterChange({
categoryId: cat.id,
feedId: undefined,
})
}
>
<span>{cat.title}</span>
</button>
);
})}
</div>
{/* True Xiaohongshu Waterfall (Masonry Grid with Staggered Heights) */}
<div className="xhs-waterfall-scroll">
{entries.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">🎉</div>
<h3>Inbox Zero</h3>
<p>No articles to display in this feed.</p>
</div>
) : (
<div className="xhs-masonry">
{entries.map((entry) => {
const coverImg = extractFirstImage(entry.content || entry.raw_content);
const snippet = stripHtml(entry.content || entry.raw_content, 90);
const relativeDate = formatRelativeDate(entry.published_at);
return (
<div key={entry.id} className="xhs-masonry-item">
<div
className={`xhs-card ${entry.is_read ? 'is-read' : ''}`}
onClick={() => handleCardClick(entry)}
>
{/* Card Cover (Xiaohongshu style image / fallback) */}
<div className="xhs-cover-wrap">
{coverImg ? (
<img
src={coverImg}
alt=""
className="xhs-cover-img"
loading="lazy"
onError={(e) => {
(e.target as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="xhs-cover-fallback">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<Rss size={20} />
<span style={{ fontSize: '0.72rem', fontWeight: 600, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{entry.feed_title}
</span>
</div>
</div>
)}
</div>
{/* Card Body */}
<div className="xhs-card-body">
<h3 className="xhs-card-title">{entry.title}</h3>
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4, marginBottom: 4 }}>
{getEntryTags(entry.variants).map((tag) => (
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
))}
{entry.variants?.['ai_summary'] && (
<span
className="variant-tag-chip"
style={{
background: 'rgba(16, 185, 129, 0.08)',
color: '#34d399',
borderColor: 'rgba(16, 185, 129, 0.25)',
}}
>
✨ AI Summary
</span>
)}
</div>
)}
{/* Footer */}
<div className="xhs-card-footer">
<div className="xhs-publisher-badge">
<span>{entry.feed_title}</span>
<span style={{ margin: '0 3px' }}>•</span>
<span>{relativeDate}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{!entry.is_read && <div className="unread-dot" />}
<button
className="btn-icon"
style={{
width: 24,
height: 24,
color: entry.is_starred ? 'var(--star)' : undefined,
}}
onClick={(e) => {
e.stopPropagation();
onToggleStar(entry, e);
}}
title={entry.is_starred ? 'Unstar' : 'Star'}
>
<Star
size={13}
fill={entry.is_starred ? 'var(--star)' : 'none'}
/>
</button>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Full-Screen Reading View with Back Arrow (Xiaohongshu post reader UX) */}
{selectedEntry && (
<div className="mobile-post-reader">
{/* Top Bar with Back Arrow */}
<div className="mobile-post-reader-header">
<button
className="btn-icon"
style={{ width: 34, height: 34 }}
onClick={() => setSelectedEntry(null)}
title="Back to feed"
>
<ArrowLeft size={18} />
</button>
<span
style={{
fontSize: '0.82rem',
fontWeight: 600,
color: 'var(--text-secondary)',
maxWidth: '40vw',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{selectedEntry.feed_title}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Sliding Ball Capsule Toggle Button */}
<button
type="button"
className={`capsule-switch-btn ${
selectedVariantKey === 'readability_full_text' ? 'active' : ''
} ${isScraping ? 'scraping' : ''}`}
onClick={() => {
if (isScraping) return;
if (selectedVariantKey === 'readability_full_text') {
setSelectedVariantKey('original');
} else {
if (hasReadability) {
setSelectedVariantKey('readability_full_text');
} else {
handleScrapeFullText();
}
}
}}
title={
selectedVariantKey === 'readability_full_text'
? 'Switch to Original RSS content'
: hasReadability
? 'Switch to Clean Full Text'
: 'Scrape and view Clean Full Text'
}
>
<div className="capsule-switch-track">
<span className="capsule-switch-label">
{isScraping
? 'Scraping...'
: selectedVariantKey === 'readability_full_text'
? 'Full Text'
: 'Original'}
</span>
<div className="capsule-switch-thumb">
{isScraping ? (
<Loader2 size={12} className="spin" />
) : selectedVariantKey === 'readability_full_text' ? (
<Zap size={12} />
) : (
<FileText size={12} />
)}
</div>
</div>
</button>
{hasAISummary && (
<button
type="button"
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
style={{
border: '1px solid',
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
borderRadius: '16px',
padding: '3px 8px',
fontSize: '0.7rem',
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
color: showAISummary ? '#34d399' : 'var(--text-muted)',
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 3,
}}
onClick={() => {
const next = !showAISummary;
setShowAISummary(next);
if (next) {
setTimeout(() => {
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
}}
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
>
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
<span>Summary</span>
</button>
)}
{hasTTS && (
<button
type="button"
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
style={{
border: 'none',
borderRadius: '16px',
padding: '3px 8px',
fontSize: '0.7rem',
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
}}
onClick={() => setSelectedVariantKey('tts')}
title="Audio Stream"
>
<Volume2 size={12} style={{ marginRight: 3 }} />
Audio
</button>
)}
<button
className="btn-icon"
style={{
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
}}
onClick={() => onToggleStar(selectedEntry)}
title={selectedEntry.is_starred ? 'Unstar' : 'Star'}
>
<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' : 'Mark Read'}
>
<CheckCircle2 size={16} />
</button>
<a
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
target="_blank"
rel="noopener noreferrer"
className="btn-icon"
title="Share Article"
>
<Share2 size={15} />
</a>
<a
href={selectedEntry.url}
target="_blank"
rel="noopener noreferrer"
className="btn-icon"
title="Open Original"
>
<ExternalLink size={15} />
</a>
</div>
</div>
{/* Reading Body */}
<div className="mobile-post-reader-content" ref={readerScrollRef}>
<div className="reader-content-canvas" style={{ padding: 0 }}>
<div className="article-header">
<div className="article-header-meta">
<span className="article-feed-badge">
{selectedEntry.feed_title}
</span>
<span className="article-meta-divider">•</span>
<span className="article-meta-item">
<Clock size={12} />
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<span className="article-meta-divider">•</span>
<span className="article-meta-item">{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
{selectedEntry.author && (
<>
<span className="article-meta-divider">•</span>
<span className="article-meta-item">
<UserIcon size={12} /> {selectedEntry.author}
</span>
</>
)}
</div>
<h1 className="reader-headline">
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
{selectedEntry.title}
</a>
</h1>
{getEntryTags(selectedEntry.variants).length > 0 && (
<div className="article-tags-row">
{getEntryTags(selectedEntry.variants).map((tag) => (
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
))}
</div>
)}
</div>
{/* AI Summary Card if present and visible */}
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
<div
ref={summaryCardRef}
style={{
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.22)',
borderRadius: 10,
padding: '1rem 1.15rem',
marginBottom: '1.5rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
<Sparkles size={15} />
<span>AI Summary & Takeaways</span>
{selectedEntry.variants.ai_summary.metadata?.model && (
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
{String(selectedEntry.variants.ai_summary.metadata.model)}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<button
type="button"
className="btn-icon"
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
title="Copy Summary"
onClick={() => {
if (selectedEntry.variants?.ai_summary?.text_value) {
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
}
}}
>
<Copy size={13} />
</button>
<button
type="button"
className="btn-icon"
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
title="Hide Summary"
onClick={() => setShowAISummary(false)}
>
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
</button>
</div>
</div>
<div
style={{
fontSize: '0.88rem',
lineHeight: 1.6,
color: 'var(--text-secondary)',
}}
dangerouslySetInnerHTML={{
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
}}
/>
</div>
)}
{/* Dynamic Content Variant Rendering */}
{selectedVariantKey === 'tts' && hasTTS ? (
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
<audio
controls
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
style={{ width: '100%' }}
/>
</div>
) : (
<div
className="reader-body"
dangerouslySetInnerHTML={{
__html: getRenderedContent(),
}}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
+170
View File
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import { Category, Feed } from '../../types';
import { api } from '../../services/api';
import { X, Plus, Loader2, AlertCircle } from 'lucide-react';
interface AddFeedModalProps {
categories: Category[];
onClose: () => void;
onFeedAdded: (feed: Feed) => void;
}
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
categories,
onClose,
onFeedAdded,
}) => {
const [feedUrl, setFeedUrl] = useState('');
const [title, setTitle] = useState('');
const [iconUrl, setIconUrl] = useState('');
const [categoryId, setCategoryId] = useState<string>('');
const [newCatTitle, setNewCatTitle] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!feedUrl.trim()) return;
setLoading(true);
setError(null);
try {
let finalCatId: string | undefined = categoryId || undefined;
// If user typed a new category name
if (newCatTitle.trim()) {
const newCat = await api.createCategory(newCatTitle.trim());
finalCatId = newCat.id;
}
const newFeed = await api.createFeed(
feedUrl.trim(),
finalCatId,
title.trim() || undefined,
iconUrl.trim() || undefined
);
onFeedAdded(newFeed);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to add feed');
} finally {
setLoading(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3 className="modal-title">Add Subscription</h3>
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && (
<div
style={{
padding: '0.65rem 0.85rem',
borderRadius: 'var(--radius-sm)',
background: 'var(--danger-soft)',
color: 'var(--danger)',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<AlertCircle size={15} />
<span>{error}</span>
</div>
)}
<div className="settings-form-row">
<label className="settings-label">Feed or Website URL *</label>
<input
type="url"
className="settings-input"
value={feedUrl}
onChange={(e) => setFeedUrl(e.target.value)}
placeholder="https://news.ycombinator.com/rss"
required
autoFocus
/>
</div>
<div className="settings-form-row">
<label className="settings-label">Custom Title (Optional)</label>
<input
type="text"
className="settings-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Auto-detect from feed title"
/>
</div>
<div className="settings-form-row">
<label className="settings-label">Custom Favicon URL (Optional)</label>
<input
type="url"
className="settings-input"
value={iconUrl}
onChange={(e) => setIconUrl(e.target.value)}
placeholder="Auto-detected from website domain if empty"
/>
</div>
<div className="settings-form-row">
<label className="settings-label">Category / Folder</label>
<select
className="settings-select"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
>
<option value="">(No Category)</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
</div>
<div className="settings-form-row">
<label className="settings-label">Or Create New Category</label>
<input
type="text"
className="settings-input"
value={newCatTitle}
onChange={(e) => setNewCatTitle(e.target.value)}
placeholder="e.g. AI & Tech"
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={loading || !feedUrl.trim()}>
{loading ? (
<>
<Loader2 size={14} className="spinning" /> Subscribing...
</>
) : (
<>
<Plus size={14} /> Subscribe
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};
+302
View File
@@ -0,0 +1,302 @@
import React, { useRef, useEffect, useState } from 'react';
import { SearchHistoryItem } from '../../types';
import { api } from '../../services/api';
import {
Rss,
Search,
Settings,
RefreshCw,
Plus,
HelpCircle,
LogOut,
History,
X,
Clock,
} from 'lucide-react';
interface HeaderProps {
search: string;
onSearchChange: (val: string) => void;
isRefreshing: boolean;
onRefreshAll: () => void;
onOpenAddFeed: () => void;
onOpenSettings: () => void;
onOpenShortcuts: () => void;
onLogout: () => void;
}
export const Header: React.FC<HeaderProps> = ({
search,
onSearchChange,
isRefreshing,
onRefreshAll,
onOpenAddFeed,
onOpenSettings,
onOpenShortcuts,
onLogout,
}) => {
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const [historyItems, setHistoryItems] = useState<SearchHistoryItem[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
// Load search history when input is focused or query changes
const loadHistory = async (query?: string) => {
try {
const items = await api.getSearchHistory(query, 8);
setHistoryItems(items || []);
} catch {
// ignore
}
};
useEffect(() => {
if (showDropdown) {
loadHistory(search);
}
}, [search, showDropdown]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
searchContainerRef.current &&
!searchContainerRef.current.contains(e.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Global `/` shortcut for focusing search bar
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key === '/' &&
document.activeElement?.tagName !== 'INPUT' &&
document.activeElement?.tagName !== 'TEXTAREA'
) {
e.preventDefault();
searchInputRef.current?.focus();
searchInputRef.current?.select();
setShowDropdown(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const handleSelectKeyword = async (keyword: string) => {
onSearchChange(keyword);
setShowDropdown(false);
try {
await api.recordSearch(keyword);
} catch {
// ignore
}
};
const handleKeyDownInSearch = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyItems.length > 0) {
setSelectedIndex((prev) => (prev + 1) % historyItems.length);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyItems.length > 0) {
setSelectedIndex((prev) => (prev <= 0 ? historyItems.length - 1 : prev - 1));
}
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && historyItems[selectedIndex]) {
handleSelectKeyword(historyItems[selectedIndex].keyword);
} else if (search.trim()) {
setShowDropdown(false);
try {
await api.recordSearch(search.trim());
loadHistory();
} catch {
// ignore
}
}
} else if (e.key === 'Escape') {
setShowDropdown(false);
}
};
const handleDeleteHistoryItem = async (e: React.MouseEvent, keyword: string) => {
e.stopPropagation();
try {
await api.deleteSearchHistory(keyword);
setHistoryItems((prev) => prev.filter((item) => item.keyword !== keyword));
} catch {
// ignore
}
};
const handleClearAllHistory = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await api.deleteSearchHistory();
setHistoryItems([]);
} catch {
// ignore
}
};
return (
<header className="app-header">
{/* Leftmost: Brand Icon & Name */}
<div className="header-left">
<div className="brand-badge" title="RSSPipe Gateway">
<Rss className="brand-icon" />
<span>RSSPipe</span>
</div>
</div>
{/* Center: Autocomplete-Enabled Search Bar */}
<div className="search-container" ref={searchContainerRef}>
<Search size={15} className="search-icon-inside" />
<input
ref={searchInputRef}
type="text"
className="search-input"
value={search}
onChange={(e) => {
onSearchChange(e.target.value);
setSelectedIndex(-1);
if (!showDropdown) setShowDropdown(true);
}}
onFocus={() => {
loadHistory(search);
setShowDropdown(true);
}}
onKeyDown={handleKeyDownInSearch}
placeholder="Search feeds, titles, or authors..."
/>
{search ? (
<button
type="button"
className="btn-icon"
style={{
position: 'absolute',
right: '0.5rem',
width: '20px',
height: '20px',
border: 'none',
background: 'transparent',
}}
onClick={() => {
onSearchChange('');
searchInputRef.current?.focus();
}}
>
<X size={13} />
</button>
) : (
<span className="search-shortcut-badge">/</span>
)}
{/* Search Autocomplete History Dropdown */}
{showDropdown && historyItems.length > 0 && (
<div className="search-autocomplete-menu">
<div className="search-autocomplete-header">
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<Clock size={12} /> Recent Searches
</span>
<button
type="button"
onClick={handleClearAllHistory}
style={{
background: 'transparent',
border: 'none',
fontSize: '0.7rem',
color: 'var(--text-muted)',
cursor: 'pointer',
padding: 0,
}}
>
Clear
</button>
</div>
{historyItems.map((item, idx) => (
<div
key={item.keyword}
className={`search-autocomplete-item ${idx === selectedIndex ? 'active' : ''}`}
onClick={() => handleSelectKeyword(item.keyword)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem', minWidth: 0, flex: 1 }}>
<History size={13} color="var(--text-muted)" />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.keyword}
</span>
</div>
<button
type="button"
className="search-autocomplete-delete-btn"
onClick={(e) => handleDeleteHistoryItem(e, item.keyword)}
title="Remove from history"
>
<X size={12} />
</button>
</div>
))}
</div>
)}
</div>
{/* Right: Actions & Settings */}
<div className="header-right">
<button
className="btn-primary"
onClick={onOpenAddFeed}
title="Add new subscription (A)"
>
<Plus size={14} />
<span>Add Feed</span>
</button>
<button
className="btn-icon"
onClick={onRefreshAll}
title="Refresh feeds (R)"
disabled={isRefreshing}
>
<RefreshCw size={15} className={isRefreshing ? 'spinning' : ''} />
</button>
{/* Settings Button */}
<button
className="btn-icon"
onClick={onOpenSettings}
title="Settings & Subscriptions (3)"
>
<Settings size={15} />
</button>
<button
className="btn-icon"
onClick={onOpenShortcuts}
title="Keyboard shortcuts (?)"
>
<HelpCircle size={15} />
</button>
<button
className="btn-icon"
onClick={onLogout}
title="Sign Out"
style={{ color: 'var(--danger)' }}
>
<LogOut size={15} />
</button>
</div>
</header>
);
};
@@ -0,0 +1,59 @@
import React from 'react';
import { X, Keyboard } from 'lucide-react';
interface KeyboardShortcutsModalProps {
onClose: () => void;
}
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
onClose,
}) => {
const shortcuts = [
{ key: 'j / ↓', desc: 'Navigate to Next article' },
{ key: 'k / ↑', desc: 'Navigate to Previous article' },
{ key: 's', desc: 'Star / Unstar selected article' },
{ key: 'm / r', desc: 'Toggle Read / Unread status' },
{ key: 'f', desc: 'Scrape clean full-text via Readability' },
{ key: 'o / v', desc: 'Open original article in new tab' },
{ key: '[', desc: 'Collapse / Expand Sidebar' },
{ key: 'V', desc: 'Toggle View Mode (3-Split ↔ Waterfall)' },
{ key: 'Shift + A', desc: 'Mark all items in view as read' },
{ key: '/', desc: 'Focus global search bar' },
{ key: 'R', desc: 'Refresh all feed subscriptions' },
{ key: 'A', desc: 'Open Add Feed modal' },
{ key: '?', desc: 'Show keyboard shortcuts cheat sheet' },
];
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Keyboard size={18} color="var(--accent-primary)" />
<h3 className="modal-title">Keyboard Shortcuts</h3>
</div>
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{shortcuts.map((sc, i) => (
<div key={i} className="shortcut-row">
<span style={{ color: 'var(--text-secondary)' }}>{sc.desc}</span>
<span className="kbd-badge">{sc.key}</span>
</div>
))}
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
};
+134
View File
@@ -0,0 +1,134 @@
import React, { useState } from 'react';
import { api } from '../../services/api';
import { User } from '../../types';
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
interface LoginViewProps {
onLoginSuccess: (user: User) => void;
}
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('admin123456');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const res = await api.login(username, password);
onLoginSuccess(res.user);
} catch (err: any) {
setError(err.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
width: '100vw',
background: 'var(--bg-app)',
padding: '1rem',
}}
>
<div
style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border-subtle)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-md)',
padding: '2.5rem',
maxWidth: '400px',
width: '100%',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.35rem' }}>
<Rss size={28} color="var(--accent-primary)" />
<h1 style={{ fontSize: '1.45rem', fontWeight: 800, color: 'var(--text-primary)' }}>RSSPipe</h1>
</div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
Minimalist Feed & Information Engine
</p>
{error && (
<div
style={{
padding: '0.65rem 0.85rem',
borderRadius: 'var(--radius-sm)',
background: 'var(--danger-soft)',
color: 'var(--danger)',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
marginBottom: '1rem',
}}
>
<AlertCircle size={15} />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="settings-form-row">
<label className="settings-label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<UserIcon size={13} /> Username
</label>
<input
type="text"
className="settings-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
required
/>
</div>
<div className="settings-form-row">
<label className="settings-label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Lock size={13} /> Password
</label>
<input
type="password"
className="settings-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
className="btn-primary"
style={{ width: '100%', height: '38px', marginTop: '0.5rem', fontSize: '0.9rem' }}
disabled={loading}
>
{loading ? (
<>
<Loader2 size={16} className="spinning" /> Signing In...
</>
) : (
<>
<span>Sign In</span>
<ArrowRight size={16} />
</>
)}
</button>
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Default credentials: <code>admin</code> / <code>admin123456</code>
</div>
</form>
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react';
import { api } from '../services/api';
import { X, UploadCloud, Download, Loader2, CheckCircle2 } from 'lucide-react';
import { api } from '../../services/api';
import { X, UploadCloud, Download, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
interface OPMLModalProps {
onClose: () => void;
@@ -56,61 +56,70 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>OPML Migration & Backup</h3>
<button className="btn-icon" onClick={onClose}>
<X size={18} />
<h3 className="modal-title">OPML Migration</h3>
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
<X size={16} />
</button>
</div>
<div className="modal-body">
{error && <div className="alert-danger">{error}</div>}
{error && (
<div
style={{
padding: '0.65rem 0.85rem',
borderRadius: 'var(--radius-sm)',
background: 'var(--danger-soft)',
color: 'var(--danger)',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<AlertCircle size={15} />
<span>{error}</span>
</div>
)}
{result && (
<div
style={{
background: 'rgba(52, 211, 153, 0.15)',
border: '1px solid var(--success)',
color: 'var(--success)',
padding: '0.85rem',
padding: '0.65rem 0.85rem',
borderRadius: 'var(--radius-sm)',
marginBottom: '1.25rem',
background: 'var(--success-soft)',
color: 'var(--success)',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: '0.875rem',
gap: '0.4rem',
}}
>
<CheckCircle2 size={18} />
<CheckCircle2 size={16} />
<span>
Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated!
Imported {result.imported} new feeds ({result.total} parsed in OPML).
</span>
</div>
)}
{/* Import Section */}
<div style={{ marginBottom: '2rem' }}>
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
Import Subscriptions (from Miniflux / Google Reader / OPML)
</h4>
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
Upload an OPML 2.0 XML file to seamlessly import all your feeds and categories.
</p>
{/* Import Dropzone */}
<div>
<div className="settings-label" style={{ marginBottom: '0.5rem' }}>
Import Subscriptions (OPML 2.0 XML)
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
style={{
border: '2px dashed var(--border)',
border: '2px dashed var(--border-strong)',
borderRadius: 'var(--radius-md)',
padding: '2rem 1rem',
padding: '1.75rem 1rem',
textAlign: 'center',
cursor: 'pointer',
background: 'var(--bg-main)',
transition: 'all 0.15s ease',
background: 'var(--bg-subtle)',
}}
>
<input
@@ -120,31 +129,21 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<UploadCloud
size={36}
style={{ color: 'var(--accent)', margin: '0 auto 0.5rem auto' }}
/>
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>
{file ? file.name : 'Click to browse or drag & drop OPML file'}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
Supports .opml or .xml
<UploadCloud size={32} color="var(--accent-primary)" style={{ margin: '0 auto 0.5rem' }} />
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
{file ? file.name : 'Click to select or drop .opml / .xml file'}
</div>
</div>
{file && (
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
<button
className="btn-primary"
onClick={handleImport}
disabled={loading}
>
<div style={{ marginTop: '0.75rem', display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn-primary" onClick={handleImport} disabled={loading}>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" /> Importing...
<Loader2 size={14} className="spinning" /> Importing...
</>
) : (
<>Import {file.name}</>
'Start Import'
)}
</button>
</div>
@@ -152,18 +151,25 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
</div>
{/* Export Section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1.25rem' }}>
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
<div style={{ borderTop: '1px solid var(--border-subtle)', paddingTop: '1rem', marginTop: '0.5rem' }}>
<div className="settings-label" style={{ marginBottom: '0.5rem' }}>
Export Subscriptions
</h4>
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
Download a complete OPML backup of all your subscribed feeds and folders.
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
Download complete backup of all feeds and categories.
</p>
<button className="btn-secondary" onClick={handleExport}>
<Download size={16} /> Export OPML File
<Download size={14} style={{ marginRight: 4 }} />
Export OPML File
</button>
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
@@ -0,0 +1,283 @@
import React from 'react';
import { FeedEntry, FilterState, Feed, Category } from '../../types';
import { formatDateOnly, stripHtml } from '../../utils';
import {
CheckCheck,
LayoutList,
AlignJustify,
Inbox,
Star,
Share2,
Sparkles,
} from 'lucide-react';
interface DesktopArticleListProps {
entries: FeedEntry[];
selectedEntryId: string | null;
onSelectEntry: (entry: FeedEntry) => void;
width: number;
listMode: 'compact' | 'rich';
onToggleListMode: (mode: 'compact' | 'rich') => void;
onMarkAllRead: () => void;
filter: FilterState;
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
feeds?: Feed[];
categories?: Category[];
onFilterChange?: (newFilter: Partial<FilterState>) => void;
}
// Extract keywords or tags from entry variants or metadata
const extractEntryList = (entry: FeedEntry, variantKey: string, metaKey: string): string[] => {
const v = entry.variants?.[variantKey]?.text_value;
if (v) {
return v.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
}
const val = entry.metadata?.[metaKey];
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string' && val) {
return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
}
return [];
};
export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
entries,
selectedEntryId,
onSelectEntry,
width,
listMode,
onToggleListMode,
onMarkAllRead,
filter,
onToggleStar,
feeds = [],
categories = [],
onFilterChange,
}) => {
const getFilterTitle = () => {
if (filter.search) return `Search: "${filter.search}"`;
if (filter.feedId) {
const activeFeed = feeds.find((f) => f.id === filter.feedId);
if (activeFeed) return activeFeed.title;
}
if (filter.categoryId) {
const activeCat = categories.find((c) => c.id === filter.categoryId);
if (activeCat) return activeCat.title;
}
if (filter.view === 'starred') return 'Starred Articles';
if (filter.view === 'all') return 'All Articles';
if (filter.view === 'shared') return 'Shared Articles';
return 'Inbox';
};
const unreadCount = entries.filter((e) => !e.is_read).length;
return (
<div className="desktop-list-pane" style={{ width: `${width}px` }}>
{/* Header Bar */}
<div className="list-header-bar">
<div className="list-title-group">
<span className="list-title-text" title={getFilterTitle()}>
{getFilterTitle()}
</span>
{unreadCount > 0 && <span className="badge badge-unread">{unreadCount}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{/* Unread / All Scope Switcher when browsing a specific feed/category */}
{(filter.feedId || filter.categoryId) && onFilterChange && (
<div className="list-scope-pill-group">
<button
className={`scope-pill-btn ${filter.view === 'inbox' ? 'active' : ''}`}
onClick={() => onFilterChange({ view: 'inbox' })}
title="Show unread articles"
>
Unread
</button>
<button
className={`scope-pill-btn ${filter.view === 'all' ? 'active' : ''}`}
onClick={() => onFilterChange({ view: 'all' })}
title="Show all articles"
>
All
</button>
</div>
)}
{unreadCount > 0 && (
<button
className="btn-icon"
onClick={onMarkAllRead}
title="Mark all as read in this view (Shift+A)"
>
<CheckCheck size={15} />
</button>
)}
<div className="list-mode-toggle">
<button
className={`list-mode-btn ${listMode === 'compact' ? 'active' : ''}`}
onClick={() => onToggleListMode('compact')}
title="Compact 2-line list"
>
<AlignJustify size={14} />
</button>
<button
className={`list-mode-btn ${listMode === 'rich' ? 'active' : ''}`}
onClick={() => onToggleListMode('rich')}
title="Expanded detailed list"
>
<LayoutList size={14} />
</button>
</div>
</div>
</div>
{/* Scrollable Articles Column */}
<div className="desktop-articles-scroll">
{entries.length === 0 ? (
<div className="reader-empty-state" style={{ height: '300px' }}>
<Inbox className="reader-empty-icon" />
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{filter.view === 'inbox' && (filter.feedId || filter.categoryId)
? 'No unread articles in this feed'
: 'No articles found'}
</div>
{filter.view === 'inbox' && (filter.feedId || filter.categoryId) && onFilterChange && (
<button
className="btn btn-secondary"
style={{ marginTop: '0.75rem', fontSize: '0.8rem', padding: '0.35rem 0.75rem' }}
onClick={() => onFilterChange({ view: 'all' })}
>
Switch to All Articles
</button>
)}
</div>
) : (
entries.map((entry) => {
const isSelected = selectedEntryId === entry.id;
const hasAi = Boolean(entry.variants?.['ai_summary']?.text_value);
const isShared = Boolean(entry.share_uuid);
// 1. Compact 2-Line Mode
if (listMode === 'compact') {
return (
<div
key={entry.id}
className={`article-row-compact ${!entry.is_read ? 'unread' : ''} ${
isSelected ? 'selected' : ''
}`}
onClick={() => onSelectEntry(entry)}
>
<div
className={`unread-indicator-dot ${entry.is_read ? 'read' : ''}`}
/>
<div className="compact-content-col">
<div className="compact-title">{entry.title}</div>
<div className="compact-date">{formatDateOnly(entry.published_at)}</div>
</div>
<div className="compact-action-col">
{isShared && (
<span className="compact-icon-item" title="Publicly shared">
<Share2 size={13} color="var(--badge-ai)" />
</span>
)}
{entry.is_starred && (
<button
className="compact-icon-btn"
onClick={(e) => onToggleStar(entry, e)}
title="Starred bookmark (Click to unstar)"
>
<Star
size={13}
color="var(--star-gold)"
fill="var(--star-gold)"
/>
</button>
)}
</div>
</div>
);
}
// 2. Non-Compact (Expanded) Mode: Title, Description, Date + (Starred, Shared, AI), Keywords + Tags
const keywords = extractEntryList(entry, 'keywords', 'keywords');
const tags = extractEntryList(entry, 'tags', 'tags');
const hasChips = keywords.length > 0 || tags.length > 0;
return (
<div
key={entry.id}
className={`article-card-rich ${!entry.is_read ? 'unread' : ''} ${
isSelected ? 'selected' : ''
}`}
onClick={() => onSelectEntry(entry)}
>
{/* Line 1: Title */}
<h3 className="card-title">{entry.title}</h3>
{/* Line 2: Description */}
<p className="card-snippet">
{stripHtml(entry.content || entry.raw_content, 140)}
</p>
{/* Line 3: Date (Left) & Starred, Shared, AI Summarized (Right Aligned) */}
<div className="card-meta-status-row">
<span className="card-date">{formatDateOnly(entry.published_at)}</span>
<div className="card-action-icons">
{/* Starred */}
{entry.is_starred && (
<button
className="compact-icon-btn"
onClick={(e) => onToggleStar(entry, e)}
title="Starred bookmark (Click to unstar)"
>
<Star
size={13}
color="var(--star-gold)"
fill="var(--star-gold)"
/>
</button>
)}
{/* Shared */}
{isShared && (
<span className="compact-icon-item" title="Publicly shared">
<Share2 size={13} color="var(--badge-ai)" />
</span>
)}
{/* AI Summarized */}
{hasAi && (
<span className="compact-icon-item" title="AI Summarized">
<Sparkles size={13} color="var(--badge-ai)" />
</span>
)}
</div>
</div>
{/* Line 4: Keywords & Tags (in different colors) */}
{hasChips && (
<div className="card-chips-row">
{keywords.map((kw, i) => (
<span key={`kw-${i}`} className="card-chip card-keyword-chip">
{kw}
</span>
))}
{tags.map((tg, i) => (
<span key={`tg-${i}`} className="card-chip card-tag-chip">
#{tg}
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
);
};
@@ -0,0 +1,554 @@
import React, { useState, useEffect, useRef } from 'react';
import { FeedEntry } from '../../types';
import { api } from '../../services/api';
import { formatDateTime, estimateReadingTime, renderMarkdownText } from '../../utils';
import {
Star,
ExternalLink,
Copy,
Zap,
Loader2,
Sparkles,
FileText,
Clock,
User as UserIcon,
CheckCircle2,
Share2,
ChevronDown,
Headphones,
Play,
Pause,
AlertCircle,
X,
} from 'lucide-react';
interface DesktopReaderProps {
entry: FeedEntry | null;
onToggleStar: (entry: FeedEntry) => void;
onToggleRead: (entry: FeedEntry) => void;
onUpdateEntry?: (entry: FeedEntry) => void;
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
currentAudioUrl?: string | null;
isPlayingAudio?: boolean;
}
export const DesktopReader: React.FC<DesktopReaderProps> = ({
entry,
onToggleStar,
onToggleRead,
onUpdateEntry,
onPlayAudio,
currentAudioUrl,
isPlayingAudio,
}) => {
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
const [isScraping, setIsScraping] = useState(false);
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [isSharing, setIsSharing] = useState(false);
const [copied, setCopied] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [showActionDropdown, setShowActionDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const autoReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Helper to mark current entry as read if unread
const markAsReadIfUnread = () => {
if (entry && !entry.is_read) {
onToggleRead(entry);
}
};
// Automatic read triggers
useEffect(() => {
if (autoReadTimerRef.current) {
clearTimeout(autoReadTimerRef.current);
autoReadTimerRef.current = null;
}
if (!entry || entry.is_read) return;
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
if (!isAutoReadEnabled) return;
// 1. If content <= 1 page on view, auto mark as read after 3 seconds
const measureTimer = setTimeout(() => {
const scrollEl = scrollAreaRef.current;
if (!scrollEl) return;
const isShortContent = scrollEl.scrollHeight <= scrollEl.clientHeight + 25;
if (isShortContent) {
autoReadTimerRef.current = setTimeout(() => {
markAsReadIfUnread();
}, 3000);
}
}, 150);
return () => {
clearTimeout(measureTimer);
if (autoReadTimerRef.current) {
clearTimeout(autoReadTimerRef.current);
autoReadTimerRef.current = null;
}
};
}, [entry?.id, entry?.is_read, selectedVariantKey]);
// 2. If click to the content view (only content, not including the right pane toolbar)
const handleContentClick = () => {
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
if (!isAutoReadEnabled || !entry || entry.is_read) return;
markAsReadIfUnread();
};
// 3. If content > 1 page on view, scroll on the right pane will auto mark as read
const handleContentScroll = () => {
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
if (!isAutoReadEnabled || !entry || entry.is_read) return;
const scrollEl = scrollAreaRef.current;
if (!scrollEl) return;
const isLongContent = scrollEl.scrollHeight > scrollEl.clientHeight + 25;
if (isLongContent && scrollEl.scrollTop > 15) {
markAsReadIfUnread();
}
};
// Sync selected variant and clear transient errors when switching articles
useEffect(() => {
setAiError(null);
if (entry?.variants?.['readability_full_text']) {
setSelectedVariantKey('readability_full_text');
} else {
setSelectedVariantKey('original');
}
}, [entry?.id]);
// Close dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowActionDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (!entry) {
return (
<div className="desktop-reader-pane">
<div className="reader-empty-state">
<FileText className="reader-empty-icon" />
<div style={{ fontSize: '1.05rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
No Article Selected
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Choose an article from the list or press <span className="kbd-badge">j</span> / <span className="kbd-badge">k</span> to navigate
</div>
</div>
</div>
);
}
const variants = entry.variants || {};
const hasReadability = Boolean(variants['readability_full_text']?.text_value);
const hasAISummary = Boolean(variants['ai_summary']?.text_value);
const ttsVariant = variants['tts'] || variants['podcast_tts'];
const isShared = Boolean(entry.share_uuid);
// Extract keywords / tags from variants or metadata
const extractList = (variantKey: string, metaKey: string): string[] => {
if (variants[variantKey]?.text_value) {
return variants[variantKey].text_value
.split(/[,,\n]/)
.map((s) => s.trim())
.filter(Boolean);
}
const val = entry.metadata?.[metaKey];
if (Array.isArray(val)) return val.map(String);
if (typeof val === 'string' && val) {
return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
}
return [];
};
const keywords = extractList('keywords', 'keywords');
const tags = extractList('tags', 'tags');
// Handle Scrape Full Text
const handleScrapeFullText = async () => {
if (!entry || isScraping) return;
setIsScraping(true);
try {
const updated = await api.scrapeEntry(entry.id);
onUpdateEntry?.(updated);
setSelectedVariantKey('readability_full_text');
} catch (err: any) {
setAiError(`Full text extraction failed: ${err.message || err}`);
} finally {
setIsScraping(false);
}
};
// Handle Primary Split Action (Toggle between Full Text and Original)
const handlePrimaryActionClick = () => {
if (!hasReadability) {
handleScrapeFullText();
} else if (selectedVariantKey === 'readability_full_text') {
setSelectedVariantKey('original');
} else {
setSelectedVariantKey('readability_full_text');
}
};
// Handle On-Demand AI Summary Generation
const handleGenerateAISummary = async () => {
setShowActionDropdown(false);
if (!entry || isGeneratingAI) return;
setIsGeneratingAI(true);
setAiError(null);
try {
const updated = await api.generateAISummary(entry.id);
onUpdateEntry?.(updated);
} catch (err: any) {
setAiError(err.message || 'AI generation failed. Please check AI settings.');
} finally {
setIsGeneratingAI(false);
}
};
// Handle Toggle Public Share
const handleToggleShare = async () => {
if (!entry || isSharing) return;
setIsSharing(true);
try {
const res = await api.toggleShare(entry.id);
onUpdateEntry?.(res.entry);
if (res.shared && res.share_uuid) {
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
navigator.clipboard.writeText(shareUrl);
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2500);
}
} catch (err: any) {
setAiError(`Sharing failed: ${err.message || err}`);
} finally {
setIsSharing(false);
}
};
const handleCopyLink = () => {
if (!entry.url) return;
navigator.clipboard.writeText(entry.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const getRenderedContent = () => {
if (selectedVariantKey === 'readability_full_text' && variants['readability_full_text']?.text_value) {
return variants['readability_full_text'].text_value;
}
return entry.content || entry.raw_content || '';
};
const isCurrentAudioActive = Boolean(
ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
);
return (
<div className="desktop-reader-pane">
{/* Top Action Toolbar */}
<div className="reader-toolbar">
{/* Left Toolbar Actions */}
<div className="reader-toolbar-left">
{/* 1. Toggle Read/Unread */}
<button
className="btn-icon"
onClick={() => onToggleRead(entry)}
title={entry.is_read ? 'Mark as unread (m)' : 'Mark as read (m)'}
>
<CheckCircle2
size={15}
color={entry.is_read ? 'var(--text-dim)' : 'var(--accent-primary)'}
/>
</button>
{/* 2. Toggle Star */}
<button
className="btn-icon"
onClick={() => onToggleStar(entry)}
title={entry.is_starred ? 'Unstar bookmark (s)' : 'Star bookmark (s)'}
>
<Star
size={15}
color={entry.is_starred ? 'var(--star-gold)' : 'var(--text-muted)'}
fill={entry.is_starred ? 'var(--star-gold)' : 'none'}
/>
</button>
{/* 3. Toggle Shared */}
<button
className={`btn-icon ${isShared ? 'active' : ''}`}
onClick={handleToggleShare}
disabled={isSharing}
title={isShared ? 'Publicly Shared (Click to unshare / copy link)' : 'Share public link'}
style={{ color: isShared ? 'var(--badge-ai)' : 'var(--text-muted)' }}
>
{isSharing ? (
<Loader2 size={15} className="spinning" />
) : (
<Share2 size={15} />
)}
</button>
{/* 4. Copy Link to Pasteboard */}
<button className="btn-icon" onClick={handleCopyLink} title="Copy link to clipboard">
<Copy size={15} color={copied ? 'var(--success)' : 'var(--text-muted)'} />
</button>
{/* 5. Go to Original Link */}
<a
href={entry.url}
target="_blank"
rel="noopener noreferrer"
className="btn-icon"
title="Open original website in new tab (o)"
>
<ExternalLink size={15} />
</a>
</div>
{/* Right Toolbar: Split Action Button & Actions Dropdown */}
<div className="reader-toolbar-right">
{shareCopied && (
<span style={{ fontSize: '0.75rem', color: 'var(--success)', fontWeight: 600, marginRight: '0.25rem' }}>
✓ Share link copied!
</span>
)}
<div className="split-action-container" ref={dropdownRef}>
{/* Primary Action Button */}
<button
className="split-action-main-btn"
onClick={handlePrimaryActionClick}
disabled={isScraping}
title={
!hasReadability
? 'Scrape clean full-text via Readability'
: selectedVariantKey === 'readability_full_text'
? 'Switch back to original RSS feed'
: 'Switch to clean full-text'
}
>
{isScraping ? (
<>
<Loader2 size={13} className="spinning" />
<span>Scraping...</span>
</>
) : !hasReadability ? (
<>
<Zap size={13} />
<span>Get Full Text</span>
</>
) : selectedVariantKey === 'readability_full_text' ? (
<>
<FileText size={13} />
<span>Original Feed</span>
</>
) : (
<>
<Zap size={13} />
<span>Full Text (Clean)</span>
</>
)}
</button>
{/* Split Dropdown Trigger */}
<button
className="split-action-arrow-btn"
onClick={() => setShowActionDropdown(!showActionDropdown)}
title="More actions"
>
<ChevronDown size={13} />
</button>
{/* Dropdown Menu (Only AI Summary and Audio Generation) */}
{showActionDropdown && (
<div className="split-dropdown-menu">
{/* 1. Generate AI Summary */}
<button
className="split-dropdown-item"
onClick={handleGenerateAISummary}
disabled={isGeneratingAI}
>
{isGeneratingAI ? (
<Loader2 size={14} className="spinning" color="var(--badge-ai)" />
) : (
<Sparkles size={14} color="var(--badge-ai)" />
)}
<span>{isGeneratingAI ? 'Generating AI Summary...' : 'Generate AI Summary'}</span>
</button>
{/* 2. Generate Audio (Placeholder) */}
<button
className="split-dropdown-item"
onClick={() => {
setShowActionDropdown(false);
alert('Audio narration synthesis is in development and will be available soon!');
}}
>
<Headphones size={14} color="var(--badge-tts)" />
<span>Generate Audio</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Reader Scrollable Container (Full width, scrollbar tracks on the far right) */}
<div
className="reader-scroll-area"
ref={scrollAreaRef}
onScroll={handleContentScroll}
onClick={handleContentClick}
>
<div className="reader-content-inner">
{/* Header Section */}
<div className="reader-header">
{/* 1. Secondary Title: Feed Title */}
<div className="reader-feed-subtitle" style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
{entry.feed_site_url && (
<img
src={`https://www.google.com/s2/favicons?domain=${entry.feed_site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`}
alt=""
style={{ width: '14px', height: '14px', borderRadius: '2px', objectFit: 'contain', flexShrink: 0 }}
onError={(e) => (e.currentTarget.style.display = 'none')}
/>
)}
<span>{entry.feed_title}</span>
</div>
{/* 2. Post Title */}
<h1 className="reader-article-title">{entry.title}</h1>
{/* 3. Author (if exists) */}
{entry.author && (
<div className="reader-meta-line">
<UserIcon size={14} className="reader-meta-icon" />
<span className="reader-author-name">{entry.author}</span>
</div>
)}
{/* 4. Updated Date (YYYY-MM-DD HH:mm) & Time to Read */}
<div className="reader-meta-line">
<Clock size={14} className="reader-meta-icon" />
<span>{formatDateTime(entry.published_at)}</span>
<span className="reader-meta-dot">•</span>
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
</div>
{/* 5. Keywords (if exists) */}
{keywords.length > 0 && (
<div className="reader-chips-row">
<span className="reader-chips-label">Keywords:</span>
<div className="reader-chips-list">
{keywords.map((kw, i) => (
<span key={i} className="reader-chip">
{kw}
</span>
))}
</div>
</div>
)}
{/* 6. Tags (if exists) */}
{tags.length > 0 && (
<div className="reader-chips-row">
<span className="reader-chips-label">Tags:</span>
<div className="reader-chips-list">
{tags.map((tg, i) => (
<span key={i} className="reader-chip reader-tag-chip">
#{tg}
</span>
))}
</div>
</div>
)}
</div>
{/* 7. Splitter Divider */}
<hr className="reader-header-divider" />
{/* Transient AI Error Message Block (In-page error on summary area) */}
{aiError && (
<div className="desktop-ai-summary-box ai-error-box">
<div className="ai-summary-header" style={{ color: 'var(--danger)', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<AlertCircle size={16} />
<span>AI Action Notice</span>
</div>
<button
onClick={() => setAiError(null)}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
title="Dismiss error"
>
<X size={14} />
</button>
</div>
<div className="ai-summary-body" style={{ color: 'var(--text-primary)', fontSize: '0.85rem' }}>
{aiError}
</div>
</div>
)}
{/* 8. AI Executive Summary (if exists) */}
{hasAISummary && (
<div className="desktop-ai-summary-box">
<div className="ai-summary-header">
<Sparkles size={16} />
<span>AI Executive Summary</span>
</div>
<div
className="ai-summary-body"
dangerouslySetInnerHTML={{
__html: renderMarkdownText(variants['ai_summary'].text_value),
}}
/>
</div>
)}
{/* 9. Audio Narration Bar (if exists) */}
{ttsVariant?.stream_url && (
<div className="desktop-inline-audio-bar">
<button
className="audio-play-circle-btn"
onClick={() => onPlayAudio?.(entry, ttsVariant.stream_url!)}
title="Play Narration Audio"
>
{isCurrentAudioActive ? <Pause size={18} /> : <Play size={18} style={{ marginLeft: 2 }} />}
</button>
<div>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
{ttsVariant.variant_key === 'podcast_tts' ? 'Podcast Audio Episode' : 'Voice Narration'}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Listen to synthesized audio for this article
</div>
</div>
</div>
)}
{/* 10. Article Content Body */}
<div
className="reader-body-rendered"
dangerouslySetInnerHTML={{ __html: getRenderedContent() }}
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,261 @@
import React, { useState } from 'react';
import { Category, Feed, FilterState, Stats } from '../../types';
import {
Inbox,
Star,
Layers,
Share2,
ChevronDown,
ChevronRight,
Folder,
Rss,
Plus,
} from 'lucide-react';
interface DesktopSidebarProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
feeds: Feed[];
stats: Stats | null;
width: number;
onOpenSettings: () => void;
onOpenAddFeed: () => void;
}
export const FeedIcon: React.FC<{ feed: Feed; size?: number }> = ({ feed, size = 15 }) => {
const [imgError, setImgError] = useState(false);
const iconUrl =
feed.icon_url ||
(feed.site_url
? `https://www.google.com/s2/favicons?domain=${feed.site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`
: '');
if (iconUrl && !imgError) {
return (
<img
src={iconUrl}
alt=""
className="feed-favicon-img"
style={{
width: `${size}px`,
height: `${size}px`,
borderRadius: '3px',
objectFit: 'contain',
flexShrink: 0,
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
return <Rss size={size} className="sidebar-item-icon" style={{ flexShrink: 0 }} />;
};
export const DesktopSidebar: React.FC<DesktopSidebarProps> = ({
filter,
onFilterChange,
categories,
feeds,
stats,
width,
onOpenAddFeed,
}) => {
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
const toggleCategory = (catId: string, e: React.MouseEvent) => {
e.stopPropagation();
setCollapsedCategories((prev) => ({
...prev,
[catId]: !prev[catId],
}));
};
const feedsByCategory = categories.map((cat) => ({
category: cat,
feeds: feeds.filter((f) => f.category_id === cat.id),
}));
const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
// Handle feed selection with safe view resolution & search reset
const handleSelectFeed = (feed: Feed) => {
onFilterChange({
feedId: feed.id,
categoryId: undefined,
search: '',
view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
});
};
// Handle category selection with safe view resolution & search reset
const handleSelectCategory = (category: Category) => {
onFilterChange({
categoryId: category.id,
feedId: undefined,
search: '',
view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
});
};
return (
<aside
className="desktop-sidebar"
style={{ width: `${width}px` }}
>
<div className="sidebar-scroll-area">
{/* 1. General Views */}
<div className="sidebar-section-title">Views</div>
<div className="sidebar-nav-list">
{/* Inbox */}
<button
className={`sidebar-item ${filter.view === 'inbox' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
onClick={() => onFilterChange({ view: 'inbox', feedId: undefined, categoryId: undefined, search: '' })}
>
<div className="sidebar-item-left">
<Inbox className="sidebar-item-icon" />
<span className="sidebar-item-title">Inbox</span>
</div>
{stats && stats.total_unread > 0 && (
<span className="badge badge-unread">{stats.total_unread}</span>
)}
</button>
{/* Starred */}
<button
className={`sidebar-item ${filter.view === 'starred' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
onClick={() => onFilterChange({ view: 'starred', feedId: undefined, categoryId: undefined, search: '' })}
>
<div className="sidebar-item-left">
<Star className="sidebar-item-icon" />
<span className="sidebar-item-title">Starred</span>
</div>
{stats && stats.total_starred > 0 && (
<span className="badge badge-starred">{stats.total_starred}</span>
)}
</button>
{/* All Articles */}
<button
className={`sidebar-item ${filter.view === 'all' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
onClick={() => onFilterChange({ view: 'all', feedId: undefined, categoryId: undefined, search: '' })}
>
<div className="sidebar-item-left">
<Layers className="sidebar-item-icon" />
<span className="sidebar-item-title">All Articles</span>
</div>
</button>
{/* Shared */}
<button
className={`sidebar-item ${filter.view === 'shared' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
onClick={() => onFilterChange({ view: 'shared', feedId: undefined, categoryId: undefined, search: '' })}
>
<div className="sidebar-item-left">
<Share2 className="sidebar-item-icon" />
<span className="sidebar-item-title">Shared</span>
</div>
{stats && (stats.total_shared || 0) > 0 && (
<span className="badge badge-unread">{stats.total_shared}</span>
)}
</button>
</div>
{/* 2. Feeds & Subscriptions */}
<div className="sidebar-section-title" style={{ marginTop: '1.15rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Feeds</span>
<button
className="btn-icon"
style={{ width: '20px', height: '20px', border: 'none', background: 'transparent' }}
onClick={onOpenAddFeed}
title="Add Feed (A)"
>
<Plus size={14} />
</button>
</div>
<div className="sidebar-nav-list">
{/* Categories */}
{feedsByCategory.map(({ category, feeds: catFeeds }) => {
const isCatCollapsed = Boolean(collapsedCategories[category.id]);
const isCatActive = filter.categoryId === category.id && !filter.feedId;
const catUnread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
return (
<div key={category.id} className="sidebar-category-group">
{/* Category Header Row */}
<div
className={`sidebar-item sidebar-category-header ${isCatActive ? 'active' : ''}`}
onClick={() => handleSelectCategory(category)}
title={category.title}
>
<div className="sidebar-item-left">
<Folder className="sidebar-item-icon" />
<span className="sidebar-item-title">{category.title}</span>
<span
className="category-chevron-btn"
onClick={(e) => toggleCategory(category.id, e)}
title={isCatCollapsed ? 'Expand folder' : 'Collapse folder'}
>
{isCatCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
</span>
</div>
{catUnread > 0 && (
<span className="badge badge-unread">{catUnread}</span>
)}
</div>
{/* Feeds in Category */}
{!isCatCollapsed && (
<div className="sidebar-category-children">
{catFeeds.map((feed) => {
const isFeedActive = filter.feedId === feed.id;
return (
<button
key={feed.id}
className={`sidebar-item ${isFeedActive ? 'active' : ''}`}
onClick={() => handleSelectFeed(feed)}
title={feed.title}
>
<div className="sidebar-item-left">
<FeedIcon feed={feed} size={15} />
<span className="sidebar-item-title">{feed.title}</span>
</div>
{feed.unread_count > 0 && (
<span className="badge badge-unread">{feed.unread_count}</span>
)}
</button>
);
})}
</div>
)}
</div>
);
})}
{/* Uncategorized Feeds */}
{uncategorizedFeeds.map((feed) => {
const isFeedActive = filter.feedId === feed.id;
return (
<button
key={feed.id}
className={`sidebar-item ${isFeedActive ? 'active' : ''}`}
onClick={() => handleSelectFeed(feed)}
title={feed.title}
>
<div className="sidebar-item-left">
<FeedIcon feed={feed} size={15} />
<span className="sidebar-item-title">{feed.title}</span>
</div>
{feed.unread_count > 0 && (
<span className="badge badge-unread">{feed.unread_count}</span>
)}
</button>
);
})}
</div>
</div>
</aside>
);
};
@@ -0,0 +1,64 @@
import React, { useState, useEffect, useCallback } from 'react';
interface ResizableSplitterProps {
onResize: (deltaX: number) => void;
onDoubleClick?: () => void;
title?: string;
}
export const ResizableSplitter: React.FC<ResizableSplitterProps> = ({
onResize,
onDoubleClick,
title = 'Drag to resize columns',
}) => {
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
setStartX(e.clientX);
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
if (deltaX !== 0) {
onResize(deltaX);
setStartX(e.clientX);
}
},
[isDragging, startX, onResize]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
} else {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<>
<div
className={`pane-splitter ${isDragging ? 'is-dragging' : ''}`}
onMouseDown={handleMouseDown}
onDoubleClick={onDoubleClick}
title={title}
/>
{isDragging && <div className="splitter-drag-overlay" />}
</>
);
};
@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react';
import { Category, Feed, FeedEntry, FilterState, Stats } from '../../types';
import { DesktopSidebar } from './DesktopSidebar';
import { DesktopArticleList } from './DesktopArticleList';
import { DesktopReader } from './DesktopReader';
import { ResizableSplitter } from './ResizableSplitter';
interface ThreePaneViewProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
feeds: Feed[];
entries: FeedEntry[];
selectedEntryId: string | null;
onSelectEntry: (entry: FeedEntry) => void;
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
onToggleRead: (entry: FeedEntry) => void;
onMarkAllRead: () => void;
onUpdateEntry?: (entry: FeedEntry) => void;
stats: Stats | null;
onOpenSettings: () => void;
onOpenAddFeed: () => void;
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
currentAudioUrl?: string | null;
isPlayingAudio?: boolean;
}
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
filter,
onFilterChange,
categories,
feeds,
entries,
selectedEntryId,
onSelectEntry,
onToggleStar,
onToggleRead,
onMarkAllRead,
onUpdateEntry,
stats,
onOpenSettings,
onOpenAddFeed,
onPlayAudio,
currentAudioUrl,
isPlayingAudio,
}) => {
// Pane Widths with persistence
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem('rsspipe_sidebar_width');
return saved ? parseInt(saved, 10) : 240;
});
const [listWidth, setListWidth] = useState<number>(() => {
const saved = localStorage.getItem('rsspipe_list_width');
return saved ? parseInt(saved, 10) : 380;
});
const [listMode, setListMode] = useState<'compact' | 'rich'>(() => {
const saved = localStorage.getItem('rsspipe_desktop_list_mode');
return saved === 'rich' ? 'rich' : 'compact';
});
useEffect(() => {
localStorage.setItem('rsspipe_sidebar_width', sidebarWidth.toString());
}, [sidebarWidth]);
useEffect(() => {
localStorage.setItem('rsspipe_list_width', listWidth.toString());
}, [listWidth]);
useEffect(() => {
localStorage.setItem('rsspipe_desktop_list_mode', listMode);
}, [listMode]);
const handleResizeSidebar = (deltaX: number) => {
setSidebarWidth((prev) => Math.max(180, Math.min(prev + deltaX, 420)));
};
const handleResizeList = (deltaX: number) => {
setListWidth((prev) => Math.max(260, Math.min(prev + deltaX, 600)));
};
const selectedEntry = entries.find((e) => e.id === selectedEntryId) || null;
return (
<div className="three-pane-container">
{/* Pane 1: Sidebar */}
<DesktopSidebar
filter={filter}
onFilterChange={onFilterChange}
categories={categories}
feeds={feeds}
stats={stats}
width={sidebarWidth}
onOpenSettings={onOpenSettings}
onOpenAddFeed={onOpenAddFeed}
/>
{/* Splitter 1 */}
<ResizableSplitter
onResize={handleResizeSidebar}
title="Drag to resize sidebar"
/>
{/* Pane 2: Article List */}
<DesktopArticleList
entries={entries}
selectedEntryId={selectedEntryId}
onSelectEntry={onSelectEntry}
width={listWidth}
listMode={listMode}
onToggleListMode={setListMode}
onMarkAllRead={onMarkAllRead}
filter={filter}
onToggleStar={onToggleStar}
feeds={feeds}
categories={categories}
onFilterChange={onFilterChange}
/>
{/* Splitter 2 */}
<ResizableSplitter
onResize={handleResizeList}
title="Drag to resize article list column"
/>
{/* Pane 3: Detail Reader */}
<DesktopReader
entry={selectedEntry}
onToggleStar={onToggleStar}
onToggleRead={onToggleRead}
onUpdateEntry={onUpdateEntry}
onPlayAudio={onPlayAudio}
currentAudioUrl={currentAudioUrl}
isPlayingAudio={isPlayingAudio}
/>
</div>
);
};
@@ -0,0 +1,241 @@
import React, { useState, useRef, useEffect } from 'react';
import { FeedEntry } from '../../types';
import {
Play,
Pause,
RotateCcw,
RotateCw,
X,
ChevronDown,
Volume2,
} from 'lucide-react';
interface DockedAudioPlayerProps {
entry: FeedEntry | null;
streamUrl: string | null;
isPlaying: boolean;
onTogglePlay: () => void;
onClose: () => void;
}
export const DockedAudioPlayer: React.FC<DockedAudioPlayerProps> = ({
entry,
streamUrl,
isPlaying,
onTogglePlay,
onClose,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [playbackRate, setPlaybackRate] = useState(1);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
if (!streamUrl) return;
if (!audioRef.current) {
audioRef.current = new Audio();
}
const audio = audioRef.current;
audio.src = streamUrl;
audio.playbackRate = playbackRate;
const updateTime = () => setCurrentTime(audio.currentTime);
const updateDuration = () => setDuration(audio.duration || 0);
const handleEnded = () => onTogglePlay();
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('ended', handleEnded);
if (isPlaying) {
audio.play().catch(() => {});
}
return () => {
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('ended', handleEnded);
audio.pause();
};
}, [streamUrl]);
useEffect(() => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.play().catch(() => {});
} else {
audioRef.current.pause();
}
}, [isPlaying]);
useEffect(() => {
if (audioRef.current) {
audioRef.current.playbackRate = playbackRate;
}
}, [playbackRate]);
if (!entry || !streamUrl) return null;
const handleSeek = (newTime: number) => {
if (audioRef.current) {
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const handleSkip = (seconds: number) => {
if (audioRef.current) {
const nextTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration));
audioRef.current.currentTime = nextTime;
setCurrentTime(nextTime);
}
};
const formatTime = (secs: number) => {
if (isNaN(secs)) return '0:00';
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s < 10 ? '0' : ''}${s}`;
};
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<>
{/* Docked Mini Player (Fixed above Bottom Navigation Bar) */}
<div className="docked-audio-player" onClick={() => setIsExpanded(true)}>
<div className="docked-progress-line" style={{ width: `${progressPercent}%` }} />
<div className="docked-audio-info">
<Volume2 size={18} color="var(--badge-tts)" style={{ flexShrink: 0 }} />
<div className="docked-audio-title">{entry.title}</div>
</div>
<div className="docked-audio-controls" onClick={(e) => e.stopPropagation()}>
<button
className="btn-icon"
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
onClick={() => handleSkip(-15)}
title="Rewind 15s"
>
<RotateCcw size={15} />
</button>
<button
className="btn-icon"
style={{ width: '32px', height: '32px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
onClick={onTogglePlay}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={16} /> : <Play size={16} style={{ marginLeft: 2 }} />}
</button>
<button
className="btn-icon"
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
onClick={() => handleSkip(15)}
title="Forward 15s"
>
<RotateCw size={15} />
</button>
<button
className="btn-icon"
style={{ width: '24px', height: '24px', border: 'none', background: 'transparent' }}
onClick={onClose}
title="Close Player"
>
<X size={14} />
</button>
</div>
</div>
{/* Expanded Full Player Sheet */}
{isExpanded && (
<div className="audio-expanded-modal" onClick={() => setIsExpanded(false)}>
<div className="audio-expanded-card" onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={() => setIsExpanded(false)}
>
<ChevronDown size={22} />
</button>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-muted)' }}>
Now Playing
</span>
<div style={{ width: 22 }} />
</div>
<div style={{ textAlign: 'center', margin: '0.5rem 0' }}>
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '0.35rem' }}>
{entry.title}
</h2>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{entry.feed_title}
</div>
</div>
{/* Scrubber */}
<div>
<input
type="range"
min={0}
max={duration || 100}
value={currentTime}
onChange={(e) => handleSeek(parseFloat(e.target.value))}
className="audio-scrubber-slider"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
{/* Play Controls */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1.5rem', margin: '0.5rem 0' }}>
<button
className="btn-icon"
style={{ width: '40px', height: '40px', border: 'none' }}
onClick={() => handleSkip(-15)}
>
<RotateCcw size={20} />
</button>
<button
className="btn-icon"
style={{ width: '56px', height: '56px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
onClick={onTogglePlay}
>
{isPlaying ? <Pause size={26} /> : <Play size={26} style={{ marginLeft: 3 }} />}
</button>
<button
className="btn-icon"
style={{ width: '40px', height: '40px', border: 'none' }}
onClick={() => handleSkip(15)}
>
<RotateCw size={20} />
</button>
</div>
{/* Speed Selector */}
<div className="audio-speed-chips">
{[0.75, 1.0, 1.25, 1.5, 2.0].map((rate) => (
<button
key={rate}
className={`audio-speed-chip ${playbackRate === rate ? 'active' : ''}`}
onClick={() => setPlaybackRate(rate)}
>
{rate}x
</button>
))}
</div>
</div>
</div>
)}
</>
);
};
+107
View File
@@ -0,0 +1,107 @@
import React from 'react';
import { Category, Feed } from '../../types';
import { Rss, Folder, Plus, ChevronRight } from 'lucide-react';
interface FeedsTabViewProps {
categories: Category[];
feeds: Feed[];
onSelectFeed: (feed: Feed) => void;
onOpenAddFeed: () => void;
}
export const FeedsTabView: React.FC<FeedsTabViewProps> = ({
categories,
feeds,
onSelectFeed,
onOpenAddFeed,
}) => {
const feedsByCategory = categories.map((cat) => ({
category: cat,
feeds: feeds.filter((f) => f.category_id === cat.id),
}));
const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', paddingBottom: 'calc(var(--bottom-nav-height) + 1.5rem)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1.2rem', fontWeight: 800, color: 'var(--text-primary)' }}>Subscriptions</h2>
<button
className="btn-primary"
style={{ height: '30px', padding: '0 0.65rem', fontSize: '0.78rem' }}
onClick={onOpenAddFeed}
>
<Plus size={14} />
<span>Add Feed</span>
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{feedsByCategory.map(({ category, feeds: catFeeds }) => {
if (catFeeds.length === 0) return null;
return (
<div key={category.id} className="settings-section-card" style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.35rem 0.5rem', fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
<Folder size={16} />
<span>{category.title}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '0.4rem' }}>
{catFeeds.map((feed) => (
<div
key={feed.id}
className="sidebar-item"
style={{ padding: '0.65rem 0.5rem' }}
onClick={() => onSelectFeed(feed)}
>
<div className="sidebar-item-left">
<Rss size={15} className="sidebar-item-icon" />
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{feed.unread_count > 0 && (
<span className="badge badge-unread">{feed.unread_count}</span>
)}
<ChevronRight size={15} color="var(--text-dim)" />
</div>
</div>
))}
</div>
</div>
);
})}
{uncategorizedFeeds.length > 0 && (
<div className="settings-section-card" style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.35rem 0.5rem', fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
<Folder size={16} />
<span>Uncategorized</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '0.4rem' }}>
{uncategorizedFeeds.map((feed) => (
<div
key={feed.id}
className="sidebar-item"
style={{ padding: '0.65rem 0.5rem' }}
onClick={() => onSelectFeed(feed)}
>
<div className="sidebar-item-left">
<Rss size={15} className="sidebar-item-icon" />
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
{feed.unread_count > 0 && (
<span className="badge badge-unread">{feed.unread_count}</span>
)}
<ChevronRight size={15} color="var(--text-dim)" />
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,215 @@
import React, { useState, useRef } from 'react';
import { FeedEntry } from '../../types';
import { formatRelativeDate, estimateReadingTime, renderMarkdownText } from '../../utils';
import {
ChevronLeft,
Star,
ExternalLink,
Share2,
Sparkles,
Volume2,
Pause,
} from 'lucide-react';
interface MobileArticleSheetProps {
entry: FeedEntry;
onClose: () => void;
onToggleStar: (entry: FeedEntry) => void;
onToggleRead?: (entry: FeedEntry) => void;
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
currentAudioUrl?: string | null;
isPlayingAudio?: boolean;
}
export const MobileArticleSheet: React.FC<MobileArticleSheetProps> = ({
entry,
onClose,
onToggleStar,
onPlayAudio,
currentAudioUrl,
isPlayingAudio,
}) => {
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const [touchDeltaX, setTouchDeltaX] = useState<number>(0);
const [isFromLeftEdge, setIsFromLeftEdge] = useState(false);
const sheetRef = useRef<HTMLDivElement>(null);
// Left-edge swipe to dismiss gesture handlers
const handleTouchStart = (e: React.TouchEvent) => {
const startX = e.touches[0].clientX;
// Only activate if swipe originates from within 45px of the left edge
if (startX <= 45) {
setIsFromLeftEdge(true);
setTouchStartX(startX);
setTouchDeltaX(0);
} else {
setIsFromLeftEdge(false);
setTouchStartX(null);
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!isFromLeftEdge || touchStartX === null) return;
const currentX = e.touches[0].clientX;
const delta = currentX - touchStartX;
if (delta > 0) {
setTouchDeltaX(delta);
}
};
const handleTouchEnd = () => {
if (isFromLeftEdge && touchDeltaX > 90) {
onClose();
} else {
setTouchDeltaX(0);
}
setIsFromLeftEdge(false);
setTouchStartX(null);
};
const handleShare = async () => {
if (navigator.share && entry.url) {
try {
await navigator.share({
title: entry.title,
url: entry.url,
});
} catch {
// ignore share cancellation
}
} else if (entry.url) {
navigator.clipboard.writeText(entry.url);
alert('Link copied to clipboard!');
}
};
const variants = entry.variants || {};
const hasAISummary = Boolean(variants['ai_summary']);
const ttsVariant = variants['tts'] || variants['podcast_tts'];
const isCurrentAudioActive = Boolean(
ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
);
const getArticleBody = () => {
if (variants['readability_full_text']?.text_value) {
return variants['readability_full_text'].text_value;
}
return entry.content || entry.raw_content || '';
};
return (
<div
ref={sheetRef}
className="mobile-article-sheet slide-in"
style={{
transform: touchDeltaX > 0 ? `translateX(${touchDeltaX}px)` : undefined,
transition: touchDeltaX > 0 ? 'none' : undefined,
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Top Header */}
<div className="mobile-sheet-header">
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={onClose}
title="Back to Feed"
>
<ChevronLeft size={22} />
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{ttsVariant?.stream_url && (
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={() => onPlayAudio?.(entry, ttsVariant.stream_url!)}
title="Play Audio"
>
{isCurrentAudioActive ? (
<Pause size={18} color="var(--badge-tts)" />
) : (
<Volume2 size={18} color="var(--badge-tts)" />
)}
</button>
)}
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={() => onToggleStar(entry)}
title="Star article"
>
<Star
size={18}
color={entry.is_starred ? 'var(--star-gold)' : 'var(--text-muted)'}
fill={entry.is_starred ? 'var(--star-gold)' : 'none'}
/>
</button>
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={handleShare}
title="Share"
>
<Share2 size={18} />
</button>
<a
href={entry.url}
target="_blank"
rel="noopener noreferrer"
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
title="Open original link"
>
<ExternalLink size={18} />
</a>
</div>
</div>
{/* Scrollable Article Body */}
<div className="mobile-sheet-scroll">
<div style={{ marginBottom: '1.25rem' }}>
<h1 style={{ fontSize: '1.45rem', fontWeight: 800, lineHeight: 1.3, color: 'var(--text-primary)', marginBottom: '0.5rem' }}>
{entry.title}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.78rem', color: 'var(--text-muted)' }}>
<span style={{ fontWeight: 600, color: 'var(--text-secondary)' }}>{entry.feed_title}</span>
<span>•</span>
<span>{formatRelativeDate(entry.published_at)}</span>
<span>•</span>
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
</div>
</div>
{/* AI Summary Box */}
{hasAISummary && (
<div className="desktop-ai-summary-box" style={{ margin: '1rem 0' }}>
<div className="ai-summary-header">
<Sparkles size={15} />
<span>AI Summary</span>
</div>
<div
className="ai-summary-body"
dangerouslySetInnerHTML={{
__html: renderMarkdownText(variants['ai_summary'].text_value),
}}
/>
</div>
)}
{/* Rendered HTML/Markdown Content */}
<div
className="reader-body-rendered"
dangerouslySetInnerHTML={{ __html: getArticleBody() }}
/>
</div>
</div>
);
};
@@ -0,0 +1,27 @@
import React from 'react';
import { getSmartArtTheme, stripHtml } from '../../utils';
interface SmartArtCardProps {
title: string;
seed: string;
excerpt?: string;
}
export const SmartArtCard: React.FC<SmartArtCardProps> = ({ title, seed, excerpt }) => {
const theme = getSmartArtTheme(seed);
const textToShow = excerpt ? stripHtml(excerpt, 90) : title;
return (
<div
className="smart-art-cover"
style={{
background: theme.bg,
}}
>
<div className="smart-art-quote" style={{ color: theme.text }}>
{textToShow}
</div>
<div className="smart-art-accent-dot" style={{ background: theme.accent }} />
</div>
);
};
@@ -0,0 +1,74 @@
import React from 'react';
import { Feed, FeedEntry } from '../../types';
import { Star, ChevronRight } from 'lucide-react';
interface StarredTabViewProps {
entries: FeedEntry[];
feeds: Feed[];
onSelectFeedFilter: (feedId?: string) => void;
onOpenAllStarred: () => void;
}
export const StarredTabView: React.FC<StarredTabViewProps> = ({
entries,
feeds,
onSelectFeedFilter,
onOpenAllStarred,
}) => {
const starredEntries = entries.filter((e) => e.is_starred);
// Group starred count by feed
const starredCountsByFeed: Record<string, number> = {};
starredEntries.forEach((e) => {
starredCountsByFeed[e.feed_id] = (starredCountsByFeed[e.feed_id] || 0) + 1;
});
const feedsWithStarred = feeds.filter((f) => starredCountsByFeed[f.id] > 0);
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', paddingBottom: 'calc(var(--bottom-nav-height) + 1.5rem)' }}>
<div style={{ marginBottom: '1.25rem' }}>
<h2 style={{ fontSize: '1.2rem', fontWeight: 800, color: 'var(--text-primary)' }}>Starred Items</h2>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
{starredEntries.length} saved articles across {feedsWithStarred.length} sources
</div>
</div>
<div className="settings-section-card" style={{ padding: '0.5rem' }}>
<div
className="sidebar-item"
style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }}
onClick={onOpenAllStarred}
>
<div className="sidebar-item-left">
<Star size={18} color="var(--star-gold)" fill="var(--star-gold)" />
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)' }}>
All Starred Articles
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className="badge badge-starred">{starredEntries.length}</span>
<ChevronRight size={16} color="var(--text-dim)" />
</div>
</div>
{feedsWithStarred.map((feed) => (
<div
key={feed.id}
className="sidebar-item"
style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }}
onClick={() => onSelectFeedFilter(feed.id)}
>
<div className="sidebar-item-left">
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className="badge badge-starred">{starredCountsByFeed[feed.id]}</span>
<ChevronRight size={16} color="var(--text-dim)" />
</div>
</div>
))}
</div>
</div>
);
};
+344
View File
@@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewPreference } from '../../types';
import { extractFirstImage, formatRelativeDate } from '../../utils';
import { XHSBottomNav, MobileTab } from './XHSBottomNav';
import { SmartArtCard } from './SmartArtCard';
import { MobileArticleSheet } from './MobileArticleSheet';
import { DockedAudioPlayer } from './DockedAudioPlayer';
import { FeedsTabView } from './FeedsTabView';
import { StarredTabView } from './StarredTabView';
import { SettingsView } from '../settings/SettingsView';
import {
Search,
Headphones,
Sparkles,
ArrowLeft,
X,
Columns3,
} from 'lucide-react';
interface WaterfallViewProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
feeds: Feed[];
entries: FeedEntry[];
stats: Stats | null;
currentUser: User;
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
onUpdateEntry?: (entry: FeedEntry) => void;
onRefreshData: () => void;
onOpenAddFeed: () => void;
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
currentAudioUrl?: string | null;
isPlayingAudio?: boolean;
onTogglePlayAudio?: () => void;
onCloseAudio?: () => void;
playingEntry?: FeedEntry | null;
viewPreference: ViewPreference;
onChangeViewPreference: (pref: ViewPreference) => void;
onSwitchToDesktop?: () => void;
}
export const WaterfallView: React.FC<WaterfallViewProps> = ({
filter,
onFilterChange,
categories,
feeds,
entries,
stats,
currentUser,
onToggleRead,
onToggleStar,
onRefreshData,
onOpenAddFeed,
onPlayAudio,
currentAudioUrl,
isPlayingAudio,
onTogglePlayAudio,
onCloseAudio,
playingEntry,
viewPreference,
onChangeViewPreference,
onSwitchToDesktop,
}) => {
const [mobileTab, setMobileTab] = useState<MobileTab>('home');
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [showSearchInput, setShowSearchInput] = useState(false);
const handleCardClick = (entry: FeedEntry) => {
setSelectedEntry(entry);
if (!entry.is_read) {
onToggleRead(entry);
}
};
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onFilterChange({ search: searchQuery.trim() });
};
const clearSearch = () => {
setSearchQuery('');
setShowSearchInput(false);
onFilterChange({ search: '' });
};
// Filter entries based on active tab and search
let displayedEntries = entries;
if (mobileTab === 'starred') {
displayedEntries = entries.filter((e) => e.is_starred);
} else if (mobileTab === 'listen') {
displayedEntries = entries.filter(
(e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
);
}
// Split into 2 columns for Masonry layout
const col1: FeedEntry[] = [];
const col2: FeedEntry[] = [];
displayedEntries.forEach((entry, idx) => {
if (idx % 2 === 0) col1.push(entry);
else col2.push(entry);
});
const playableCount = entries.filter(
(e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
).length;
const currentFeedFilter = feeds.find((f) => f.id === filter.feedId);
return (
<div className="waterfall-app-root">
{/* Mobile Top Header */}
<header className="mobile-top-header">
{currentFeedFilter ? (
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={() => onFilterChange({ feedId: undefined })}
>
<ArrowLeft size={20} />
</button>
) : (
<div className="mobile-header-title">RSSPipe</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flex: showSearchInput ? 1 : 'none', marginLeft: showSearchInput ? '0.5rem' : 0 }}>
{showSearchInput ? (
<form onSubmit={handleSearchSubmit} style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.3rem' }}>
<input
type="text"
className="search-input"
placeholder="Search articles..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<button
type="button"
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={clearSearch}
>
<X size={16} />
</button>
</form>
) : (
<>
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={() => setShowSearchInput(true)}
title="Search"
>
<Search size={18} />
</button>
{onSwitchToDesktop && (
<button
className="btn-icon"
style={{ border: 'none', background: 'transparent' }}
onClick={onSwitchToDesktop}
title="Switch to 3-Split Desktop View (V)"
>
<Columns3 size={18} />
</button>
)}
</>
)}
</div>
</header>
{/* Top Category Pill Tabs (In Home View) */}
{mobileTab === 'home' && !filter.feedId && (
<div className="mobile-tabs-pill-bar">
<button
className={`mobile-tab-pill ${!filter.categoryId && filter.view === 'inbox' ? 'active' : ''}`}
onClick={() => onFilterChange({ categoryId: undefined, view: 'inbox' })}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
className={`mobile-tab-pill ${filter.categoryId === cat.id ? 'active' : ''}`}
onClick={() => onFilterChange({ categoryId: cat.id, view: 'all' })}
>
{cat.title}
</button>
))}
</div>
)}
{/* Main Tab Content */}
{mobileTab === 'feeds' ? (
<FeedsTabView
categories={categories}
feeds={feeds}
onSelectFeed={(feed) => {
onFilterChange({ feedId: feed.id, view: 'all' });
setMobileTab('home');
}}
onOpenAddFeed={onOpenAddFeed}
/>
) : mobileTab === 'starred' ? (
<StarredTabView
entries={entries.filter((e) => e.is_starred)}
feeds={feeds}
onSelectFeedFilter={(feedId) => {
onFilterChange({ feedId, view: 'starred' });
setMobileTab('home');
}}
onOpenAllStarred={() => {
onFilterChange({ view: 'starred', feedId: undefined });
setMobileTab('home');
}}
/>
) : mobileTab === 'settings' ? (
<div style={{ flex: 1, overflowY: 'auto' }}>
<SettingsView
currentUser={currentUser}
feeds={feeds}
categories={categories}
stats={stats}
onRefreshData={onRefreshData}
onOpenAddFeed={onOpenAddFeed}
onBack={() => setMobileTab('home')}
viewPreference={viewPreference}
onChangeViewPreference={onChangeViewPreference}
/>
</div>
) : (
/* Waterfall 2-Column Grid (Home / Listen / Filtered Feeds) */
<div className="waterfall-scroll-area">
{displayedEntries.length === 0 ? (
<div className="reader-empty-state" style={{ height: '300px' }}>
<div style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
No posts found
</div>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
Pull to refresh or check other categories
</div>
</div>
) : (
<div className="waterfall-masonry-grid">
{/* Column 1 */}
<div className="waterfall-column">
{col1.map((entry) => renderCard(entry))}
</div>
{/* Column 2 */}
<div className="waterfall-column">
{col2.map((entry) => renderCard(entry))}
</div>
</div>
)}
</div>
)}
{/* Docked Mini Audio Player (when audio is active) */}
{currentAudioUrl && (
<DockedAudioPlayer
entry={playingEntry || selectedEntry}
streamUrl={currentAudioUrl}
isPlaying={Boolean(isPlayingAudio)}
onTogglePlay={() => onTogglePlayAudio?.()}
onClose={() => onCloseAudio?.()}
/>
)}
{/* iOS 5-Tab Bottom Navigation Bar */}
<XHSBottomNav
activeTab={mobileTab}
onTabChange={(tab) => {
setMobileTab(tab);
if (tab === 'starred') {
onFilterChange({ view: 'starred' });
} else if (tab === 'home') {
onFilterChange({ view: 'inbox', feedId: undefined });
}
}}
stats={stats}
playableCount={playableCount}
/>
{/* Slide-In Full-Screen Article Reader Sheet with Left-Edge Swipe Dismiss */}
{selectedEntry && (
<MobileArticleSheet
entry={selectedEntry}
onClose={() => setSelectedEntry(null)}
onToggleStar={onToggleStar}
onToggleRead={onToggleRead}
onPlayAudio={onPlayAudio}
currentAudioUrl={currentAudioUrl}
isPlayingAudio={isPlayingAudio}
/>
)}
</div>
);
function renderCard(entry: FeedEntry) {
const coverImage = extractFirstImage(entry.content || entry.raw_content);
const hasAi = Boolean(entry.variants?.['ai_summary']);
const hasTts = Boolean(entry.variants?.['tts'] || entry.variants?.['podcast_tts']);
return (
<div
key={entry.id}
className={`xhs-card ${entry.is_read ? 'read' : ''}`}
onClick={() => handleCardClick(entry)}
>
{/* Cover / SmartArt Preview */}
{coverImage ? (
<div className="xhs-card-image-wrap">
<img src={coverImage} alt="" className="xhs-card-image" loading="lazy" />
</div>
) : (
<SmartArtCard
title={entry.title}
seed={entry.feed_title || entry.id}
excerpt={entry.content || entry.raw_content}
/>
)}
{/* Card Body & Metadata */}
<div className="xhs-card-body">
<div className="xhs-card-title">{entry.title}</div>
<div className="xhs-card-footer">
<div className="xhs-card-author-group">
<span className="xhs-card-feed-title">{entry.feed_title}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{hasAi && <Sparkles size={12} color="var(--badge-ai)" />}
{hasTts && <Headphones size={12} color="var(--badge-tts)" />}
<span>{formatRelativeDate(entry.published_at)}</span>
</div>
</div>
</div>
</div>
);
}
};
@@ -0,0 +1,78 @@
import React from 'react';
import { Home, Rss, Headphones, Star, Settings } from 'lucide-react';
import { Stats } from '../../types';
export type MobileTab = 'home' | 'feeds' | 'listen' | 'starred' | 'settings';
interface XHSBottomNavProps {
activeTab: MobileTab;
onTabChange: (tab: MobileTab) => void;
stats: Stats | null;
playableCount?: number;
}
export const XHSBottomNav: React.FC<XHSBottomNavProps> = ({
activeTab,
onTabChange,
stats,
playableCount = 0,
}) => {
return (
<nav className="ios-bottom-nav">
<button
className={`bottom-nav-item ${activeTab === 'home' ? 'active' : ''}`}
onClick={() => onTabChange('home')}
>
<Home className="bottom-nav-icon" />
<span>Home</span>
{stats && stats.total_unread > 0 && (
<span className="bottom-nav-badge">
{stats.total_unread > 99 ? '99+' : stats.total_unread}
</span>
)}
</button>
<button
className={`bottom-nav-item ${activeTab === 'feeds' ? 'active' : ''}`}
onClick={() => onTabChange('feeds')}
>
<Rss className="bottom-nav-icon" />
<span>Feeds</span>
</button>
<button
className={`bottom-nav-item ${activeTab === 'listen' ? 'active' : ''}`}
onClick={() => onTabChange('listen')}
>
<Headphones className="bottom-nav-icon" />
<span>Listen</span>
{playableCount > 0 && (
<span className="bottom-nav-badge" style={{ background: 'var(--badge-tts)' }}>
{playableCount}
</span>
)}
</button>
<button
className={`bottom-nav-item ${activeTab === 'starred' ? 'active' : ''}`}
onClick={() => onTabChange('starred')}
>
<Star className="bottom-nav-icon" />
<span>Starred</span>
{stats && stats.total_starred > 0 && (
<span className="bottom-nav-badge" style={{ background: 'var(--star-gold)' }}>
{stats.total_starred}
</span>
)}
</button>
<button
className={`bottom-nav-item ${activeTab === 'settings' ? 'active' : ''}`}
onClick={() => onTabChange('settings')}
>
<Settings className="bottom-nav-icon" />
<span>Settings</span>
</button>
</nav>
);
};
File diff suppressed because it is too large Load Diff