import React, { useState, useEffect, useCallback } from 'react'; import { api } from './services/api'; import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode } from './types'; import { LoginView } from './components/LoginView'; import { Header } from './components/Header'; import { ThreePaneView } from './components/ThreePaneView'; import { WaterfallView } from './components/WaterfallView'; import { SettingsView } from './components/SettingsView'; import { AddFeedModal } from './components/AddFeedModal'; import { OPMLModal } from './components/OPMLModal'; import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal'; export const App: React.FC = () => { const [currentUser, setCurrentUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); // App State with localStorage Persistence const [filter, setFilter] = useState(() => { const saved = localStorage.getItem('rsspipe_filter'); if (saved) { try { return JSON.parse(saved); } catch { // ignore } } return { view: 'inbox', search: '' }; }); const [viewMode, setViewMode] = useState(() => { const saved = localStorage.getItem('rsspipe_view_mode') as ViewMode | null; if (saved && (saved === 'split' || saved === 'waterfall' || saved === 'settings')) { return saved; } return window.innerWidth < 768 ? 'waterfall' : 'split'; }); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true'; }); const [selectedEntryId, setSelectedEntryId] = useState(() => { return localStorage.getItem('rsspipe_selected_entry_id'); }); const [activeArticle, setActiveArticle] = useState(null); const [categories, setCategories] = useState([]); const [feeds, setFeeds] = useState([]); const [entries, setEntries] = useState([]); const [stats, setStats] = useState(null); const [focusedIndex, setFocusedIndex] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); const [showAddFeedModal, setShowAddFeedModal] = useState(false); const [showOPMLModal, setShowOPMLModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); // Persist state updates to localStorage useEffect(() => { localStorage.setItem('rsspipe_filter', JSON.stringify(filter)); }, [filter]); useEffect(() => { localStorage.setItem('rsspipe_view_mode', viewMode); }, [viewMode]); useEffect(() => { localStorage.setItem('rsspipe_sidebar_collapsed', sidebarCollapsed ? 'true' : 'false'); }, [sidebarCollapsed]); // Check auth session useEffect(() => { const checkAuth = async () => { const token = localStorage.getItem('rsspipe_jwt'); if (!token) { setAuthLoading(false); return; } try { const user = await api.getMe(); setCurrentUser(user); } catch { localStorage.removeItem('rsspipe_jwt'); } finally { setAuthLoading(false); } }; checkAuth(); const handleUnauthorized = () => { setCurrentUser(null); }; window.addEventListener('auth:unauthorized', handleUnauthorized); return () => window.removeEventListener('auth:unauthorized', handleUnauthorized); }, []); // Load sidebar data (Feeds, Categories, Stats) const loadSidebarData = useCallback(async () => { if (!currentUser) return; try { const [catList, feedList, statData] = await Promise.all([ api.listCategories(), api.listFeeds(), api.getStats(), ]); setCategories(catList); setFeeds(feedList); setStats(statData); } catch (err) { console.error('Failed to load sidebar data:', err); } }, [currentUser]); // Load entries matching current filter const loadEntries = useCallback(async () => { if (!currentUser) return; try { const res = await api.listEntries({ view: filter.view, category_id: filter.categoryId, feed_id: filter.feedId, search: filter.search, limit: 100, }); setEntries(res.entries); } catch (err) { console.error('Failed to load entries:', err); } }, [currentUser, filter]); // Initial load useEffect(() => { if (currentUser) { loadSidebarData(); loadEntries(); } }, [currentUser, loadSidebarData, loadEntries]); // Sync focused index & active article when entries change without disrupting reading state useEffect(() => { if (entries.length > 0) { if (activeArticle) { const found = entries.find((e) => e.id === activeArticle.id); if (found) { setActiveArticle(found); setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id)); return; } // Active article remains in reader canvas from prior feed, but list is unselected setFocusedIndex(-1); return; } if (selectedEntryId) { const found = entries.find((e) => e.id === selectedEntryId); if (found) { setActiveArticle(found); setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId)); return; } // If not found in current list, load directly so reading pane stays populated api.getEntry(selectedEntryId).then((entry) => { setActiveArticle(entry); }).catch(() => { // fallback to top if (entries[0]) { setFocusedIndex(0); setActiveArticle(entries[0]); setSelectedEntryId(entries[0].id); localStorage.setItem('rsspipe_selected_entry_id', entries[0].id); } }); return; } setFocusedIndex(0); setActiveArticle(entries[0]); setSelectedEntryId(entries[0].id); localStorage.setItem('rsspipe_selected_entry_id', entries[0].id); } }, [entries]); const handleSelectEntry = (index: number) => { setFocusedIndex(index); if (entries[index]) { setActiveArticle(entries[index]); setSelectedEntryId(entries[index].id); localStorage.setItem('rsspipe_selected_entry_id', entries[index].id); } }; // Refresh data only on initial mount or when user clicks refresh / changes filter // Toggle Read Action with optimistic update const handleToggleRead = async (entry: FeedEntry, e?: React.MouseEvent) => { if (e) e.stopPropagation(); const newStatus = !entry.is_read; // Optimistic update setEntries((prev) => prev.map((item) => item.id === entry.id ? { ...item, is_read: newStatus } : item ) ); if (activeArticle && activeArticle.id === entry.id) { setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev)); } try { await api.updateRead(entry.id, newStatus); loadSidebarData(); } catch (err) { console.error('Failed to update read status:', err); // Revert on error setEntries((prev) => prev.map((item) => item.id === entry.id ? { ...item, is_read: !newStatus } : item ) ); if (activeArticle && activeArticle.id === entry.id) { setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev)); } } }; // Toggle Star Action with optimistic update const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => { if (e) e.stopPropagation(); const newStatus = !entry.is_starred; // Optimistic update setEntries((prev) => prev.map((item) => item.id === entry.id ? { ...item, is_starred: newStatus } : item ) ); if (activeArticle && activeArticle.id === entry.id) { setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev)); } try { await api.updateStar(entry.id, newStatus); loadSidebarData(); } catch (err) { console.error('Failed to update star status:', err); setEntries((prev) => prev.map((item) => item.id === entry.id ? { ...item, is_starred: !newStatus } : item ) ); if (activeArticle && activeArticle.id === entry.id) { setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev)); } } }; // Mark All Read const handleMarkAllRead = async () => { try { await api.markAllRead(filter.feedId, filter.categoryId); setEntries((prev) => prev.map((item) => ({ ...item, is_read: true }))); if (activeArticle) { setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev)); } loadSidebarData(); } catch (err) { console.error('Failed to mark all read:', err); } }; // Refresh All Feeds const handleRefreshAll = async () => { setIsRefreshing(true); try { await api.refreshAllFeeds(); setTimeout(() => { loadSidebarData(); loadEntries(); setIsRefreshing(false); }, 2500); } catch (err) { console.error('Failed to refresh feeds:', err); setIsRefreshing(false); } }; // Global Keyboard Shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't intercept inside input fields if ( document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.tagName === 'SELECT' ) { if (e.key === 'Escape') { (document.activeElement as HTMLElement).blur(); } return; } if (e.key === 'Escape') { setShowAddFeedModal(false); setShowOPMLModal(false); setShowShortcutsModal(false); return; } switch (e.key) { case '[': // Toggle Sidebar e.preventDefault(); setSidebarCollapsed((prev) => !prev); break; case 'j': case 'J': // Next item e.preventDefault(); if (entries.length > 0) { setFocusedIndex((prev) => { const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1); if (entries[next]) { setActiveArticle(entries[next]); setSelectedEntryId(entries[next].id); localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); } return next; }); } break; case 'k': case 'K': // Previous item e.preventDefault(); if (entries.length > 0) { setFocusedIndex((prev) => { const next = prev < 0 ? 0 : Math.max(prev - 1, 0); if (entries[next]) { setActiveArticle(entries[next]); setSelectedEntryId(entries[next].id); localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); } return next; }); } break; case 'v': case 'V': // Open original link { const targetUrl = activeArticle?.url || entries[focusedIndex]?.url; if (targetUrl) { e.preventDefault(); window.open(targetUrl, '_blank'); } } break; case 's': case 'S': // Toggle Star { const target = activeArticle || entries[focusedIndex]; if (target) { e.preventDefault(); handleToggleStar(target); } } break; case 'm': case 'M': // Toggle Read / Unread { const target = activeArticle || entries[focusedIndex]; if (target) { e.preventDefault(); handleToggleRead(target); } } break; case 'e': case 'E': // Archive / Mark read and advance { const target = activeArticle || entries[focusedIndex]; if (target) { e.preventDefault(); if (!target.is_read) { handleToggleRead(target); } if (entries.length > 0) { setFocusedIndex((prev) => { const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1); if (entries[next]) { setActiveArticle(entries[next]); setSelectedEntryId(entries[next].id); localStorage.setItem('rsspipe_selected_entry_id', entries[next].id); } return next; }); } } } break; case 'a': case 'A': // Add feed modal if (!e.shiftKey && !e.metaKey && !e.ctrlKey) { e.preventDefault(); setShowAddFeedModal(true); } break; case 'r': case 'R': // Refresh feeds e.preventDefault(); handleRefreshAll(); break; case '1': // Switch to Desktop 3-Split View e.preventDefault(); setViewMode('split'); break; case '2': // Switch to Xiaohongshu Waterfall View e.preventDefault(); setViewMode('waterfall'); break; case '3': // Switch to Settings View e.preventDefault(); setViewMode('settings'); break; case '?': // Shortcuts modal e.preventDefault(); setShowShortcutsModal(true); break; default: if (e.shiftKey && e.key === 'A') { e.preventDefault(); if (window.confirm('Mark all items in this view as read?')) { handleMarkAllRead(); } } break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [entries, focusedIndex, activeArticle]); if (authLoading) { return (
Loading RSSPipe...
); } if (!currentUser) { return setCurrentUser(user)} />; } return (
{/* App Header */}
setFilter((prev) => ({ ...prev, search: val }))} viewMode={viewMode} onViewModeChange={setViewMode} isRefreshing={isRefreshing} onRefreshAll={handleRefreshAll} onOpenAddFeed={() => setShowAddFeedModal(true)} onOpenShortcuts={() => setShowShortcutsModal(true)} onLogout={async () => { await api.logout(); setCurrentUser(null); }} sidebarCollapsed={sidebarCollapsed} onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)} /> {/* Main View Mode Content */} {viewMode === 'split' ? ( setFilter((prev) => ({ ...prev, ...newF }))} categories={categories} feeds={feeds} stats={stats} entries={entries} selectedIndex={focusedIndex} activeArticle={activeArticle} onSelectEntry={handleSelectEntry} onToggleRead={handleToggleRead} onToggleStar={handleToggleStar} onMarkAllRead={handleMarkAllRead} onUpdateEntry={(updated) => { setActiveArticle(updated); setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e))); }} sidebarCollapsed={sidebarCollapsed} /> ) : viewMode === 'waterfall' ? ( setFilter((prev) => ({ ...prev, ...newF }))} categories={categories} entries={entries} onToggleRead={handleToggleRead} onToggleStar={handleToggleStar} onUpdateEntry={(updated) => { setActiveArticle(updated); setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e))); }} /> ) : ( { loadSidebarData(); loadEntries(); }} onOpenAddFeed={() => setShowAddFeedModal(true)} /> )} {/* Modals */} {showAddFeedModal && ( setShowAddFeedModal(false)} onFeedAdded={() => { loadSidebarData(); loadEntries(); }} /> )} {showOPMLModal && ( setShowOPMLModal(false)} onImportComplete={() => { loadSidebarData(); loadEntries(); }} /> )} {showShortcutsModal && ( setShowShortcutsModal(false)} /> )}
); };