import React, { useState, useEffect, useCallback } from 'react'; import { api } from './services/api'; import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode, ViewPreference } from './types'; import { LoginView } from './components/common/LoginView'; import { Header } from './components/common/Header'; import { ThreePaneView } from './components/desktop/ThreePaneView'; import { WaterfallView } from './components/mobile/WaterfallView'; import { SettingsView } from './components/settings/SettingsView'; import { AddFeedModal } from './components/common/AddFeedModal'; import { OPMLModal } from './components/common/OPMLModal'; import { KeyboardShortcutsModal } from './components/common/KeyboardShortcutsModal'; export const App: React.FC = () => { const [currentUser, setCurrentUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); // App Filter State with Persistence const [filter, setFilter] = useState(() => { const saved = localStorage.getItem('rsspipe_filter'); if (saved) { try { return JSON.parse(saved); } catch { // ignore } } return { view: 'inbox', search: '' }; }); // View Preference: 'auto' | 'mobile' | 'desktop' const [viewPreference, setViewPreference] = useState(() => { const saved = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null; return saved && (saved === 'auto' || saved === 'mobile' || saved === 'desktop') ? saved : 'auto'; }); // Active View Mode: 'split' | 'waterfall' | 'settings' const [viewMode, setViewMode] = useState(() => { const savedPref = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null; if (savedPref === 'mobile') return 'waterfall'; if (savedPref === 'desktop') return 'split'; const isNarrow = typeof window !== 'undefined' ? window.innerWidth < 768 : false; return isNarrow ? 'waterfall' : 'split'; }); const [lastContentMode, setLastContentMode] = useState<'split' | 'waterfall'>('split'); const [selectedEntryId, setSelectedEntryId] = useState(() => { return localStorage.getItem('rsspipe_selected_entry_id'); }); const [categories, setCategories] = useState([]); const [feeds, setFeeds] = useState([]); const [entries, setEntries] = useState([]); const [stats, setStats] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [showAddFeedModal, setShowAddFeedModal] = useState(false); const [showOPMLModal, setShowOPMLModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); // Global Audio Engine State const [currentAudioUrl, setCurrentAudioUrl] = useState(null); const [isPlayingAudio, setIsPlayingAudio] = useState(false); const [playingEntry, setPlayingEntry] = useState(null); // Persist filter useEffect(() => { localStorage.setItem('rsspipe_filter', JSON.stringify(filter)); }, [filter]); // Persist view preference & synchronize active view layout useEffect(() => { localStorage.setItem('rsspipe_view_preference', viewPreference); const handleAutoResize = () => { if (viewMode === 'settings') return; if (viewPreference === 'mobile') { setViewMode('waterfall'); } else if (viewPreference === 'desktop') { setViewMode('split'); } else { const isNarrow = window.innerWidth < 768; setViewMode(isNarrow ? 'waterfall' : 'split'); } }; handleAutoResize(); window.addEventListener('resize', handleAutoResize); return () => window.removeEventListener('resize', handleAutoResize); }, [viewPreference, viewMode]); useEffect(() => { if (viewMode === 'split' || viewMode === 'waterfall') { setLastContentMode(viewMode); } }, [viewMode]); useEffect(() => { if (selectedEntryId) { localStorage.setItem('rsspipe_selected_entry_id', selectedEntryId); } }, [selectedEntryId]); // Check auth session on startup useEffect(() => { const checkAuth = async () => { const token = localStorage.getItem('rsspipe_jwt'); if (!token) { setAuthLoading(false); return; } try { const user = await api.getMe(); setCurrentUser(user); if (user.preferences) { if (user.preferences.auto_read !== undefined) { localStorage.setItem('rsspipe_auto_read_scroll', user.preferences.auto_read ? 'true' : 'false'); } if (user.preferences.view_preference) { setViewPreference(user.preferences.view_preference); localStorage.setItem('rsspipe_view_preference', user.preferences.view_preference); } } } catch { localStorage.removeItem('rsspipe_jwt'); } finally { setAuthLoading(false); } }; checkAuth(); const handleUnauthorized = () => { setCurrentUser(null); }; window.addEventListener('auth:unauthorized', handleUnauthorized); const syncPreferences = async () => { const token = localStorage.getItem('rsspipe_jwt'); if (!token) return; try { const prefs = await api.getUserPreferences(); if (prefs) { if (prefs.auto_read !== undefined) { localStorage.setItem('rsspipe_auto_read_scroll', prefs.auto_read ? 'true' : 'false'); } if (prefs.view_preference) { setViewPreference(prefs.view_preference); localStorage.setItem('rsspipe_view_preference', prefs.view_preference); } } } catch { // ignore } }; window.addEventListener('focus', syncPreferences); const handleVisibilityChange = () => { if (document.visibilityState === 'visible') syncPreferences(); }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('auth:unauthorized', handleUnauthorized); window.removeEventListener('focus', syncPreferences); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); // Load sidebar data (Categories, Feeds, 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]); const [totalEntries, setTotalEntries] = useState(0); const [isLoadingMore, setIsLoadingMore] = useState(false); // Load entries matching filter const loadEntries = useCallback(async () => { if (!currentUser) return; try { const res = await api.listEntries({ view: filter.view, feed_id: filter.feedId, category_id: filter.categoryId, search: filter.search || undefined, limit: 50, offset: 0, }); setEntries(res.entries || []); setTotalEntries(res.total || 0); if (res.entries && res.entries.length > 0) { setSelectedEntryId((prev) => { if (prev && res.entries.some((e) => e.id === prev)) { return prev; } return res.entries[0].id; }); } else { setSelectedEntryId(null); } } catch (err) { console.error('Failed to load entries:', err); } }, [currentUser, filter]); // Load more entries for infinite scrolling const handleLoadMore = async () => { if (isLoadingMore || entries.length >= totalEntries || !currentUser) return; setIsLoadingMore(true); try { const res = await api.listEntries({ view: filter.view, feed_id: filter.feedId, category_id: filter.categoryId, search: filter.search || undefined, limit: 50, offset: entries.length, }); if (res.entries && res.entries.length > 0) { setEntries((prev) => [...prev, ...res.entries]); } setTotalEntries(res.total || 0); } catch (err) { console.error('Failed to load more entries:', err); } finally { setIsLoadingMore(false); } }; useEffect(() => { if (currentUser) { loadSidebarData(); loadEntries(); } }, [currentUser, loadSidebarData, loadEntries]); // Global Refresh Action with guaranteed animation feedback duration const handleRefreshAll = async () => { if (isRefreshing) return; setIsRefreshing(true); const minDelay = new Promise((resolve) => setTimeout(resolve, 600)); try { await Promise.all([ api.refreshAllFeeds(), minDelay, ]); await Promise.all([loadSidebarData(), loadEntries()]); } catch (err) { console.error('Refresh all failed:', err); } finally { setIsRefreshing(false); } }; // Toggle Read Action const handleToggleRead = async (entry: FeedEntry) => { const nextRead = !entry.is_read; setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, is_read: nextRead } : e)) ); try { await api.updateRead(entry.id, nextRead); loadSidebarData(); } catch (err) { console.error('Update read failed:', err); } }; // Toggle Star Action const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => { if (e) e.stopPropagation(); const nextStar = !entry.is_starred; setEntries((prev) => prev.map((e) => (e.id === entry.id ? { ...e, is_starred: nextStar } : e)) ); try { await api.updateStar(entry.id, nextStar); loadSidebarData(); } catch (err) { console.error('Update star failed:', err); } }; // Mark All As Read Action const handleMarkAllRead = async () => { try { await api.markAllRead(filter.feedId, filter.categoryId); setEntries((prev) => prev.map((e) => ({ ...e, is_read: true }))); loadSidebarData(); } catch (err) { console.error('Mark all read failed:', err); } }; // Connect Real-Time SSE Stream for live state sync across tabs and async background jobs useEffect(() => { if (!currentUser) return; const disconnect = api.connectEventStream((eventType, data) => { if (eventType === 'job_completed' || eventType === 'post_updated' || eventType === 'variant_updated') { const updatedEntry = data?.entry || data; if (updatedEntry && updatedEntry.id) { setEntries((prev) => prev.map((e) => e.id === updatedEntry.id ? { ...e, ...updatedEntry, variants: { ...(e.variants || {}), ...(updatedEntry.variants || {}) }, } : e ) ); } } else if (eventType === 'post_created') { loadSidebarData(); } else if (eventType === 'preferences_updated') { if (data && typeof data === 'object') { if (data.auto_read !== undefined) { localStorage.setItem('rsspipe_auto_read_scroll', data.auto_read ? 'true' : 'false'); } if (data.view_preference) { setViewPreference(data.view_preference); localStorage.setItem('rsspipe_view_preference', data.view_preference); } } } }); return () => disconnect(); }, [currentUser, loadSidebarData]); // Update Single Entry Action const handleUpdateEntry = (updated: FeedEntry) => { setEntries((prev) => prev.map((e) => e.id === updated.id ? { ...e, ...updated, variants: { ...(e.variants || {}), ...(updated.variants || {}) }, } : e ) ); loadSidebarData(); }; // Audio Playback Actions const handlePlayAudio = (entry: FeedEntry, streamUrl: string) => { if (currentAudioUrl === streamUrl) { setIsPlayingAudio(!isPlayingAudio); } else { setCurrentAudioUrl(streamUrl); setPlayingEntry(entry); setIsPlayingAudio(true); } }; const handleTogglePlayAudio = () => { setIsPlayingAudio(!isPlayingAudio); }; const handleCloseAudio = () => { setIsPlayingAudio(false); setCurrentAudioUrl(null); setPlayingEntry(null); }; // View Preference Change Handler const handleChangeViewPreference = (pref: ViewPreference) => { setViewPreference(pref); if (pref === 'desktop') { setViewMode('split'); } else if (pref === 'mobile') { setViewMode('waterfall'); } else { const isNarrow = window.innerWidth < 768; setViewMode(isNarrow ? 'waterfall' : 'split'); } }; // Global Keyboard Shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { return; } if (e.key === 'Escape') { setShowAddFeedModal(false); setShowOPMLModal(false); setShowShortcutsModal(false); if (viewMode === 'settings') { setViewMode(lastContentMode); } return; } if (e.key === '?' || (e.shiftKey && e.key === '/')) { e.preventDefault(); setShowShortcutsModal(true); return; } if (e.key === '1') { setViewMode('split'); return; } if (e.key === '2') { setViewMode('waterfall'); return; } if (e.key === '3') { setViewMode('settings'); return; } if (e.key === 'r' || e.key === 'R') { if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); handleRefreshAll(); return; } } if (e.key === 'a' || e.key === 'A') { if (e.shiftKey) { e.preventDefault(); handleMarkAllRead(); } else if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); setShowAddFeedModal(true); } return; } // 3-Split specific shortcuts if (viewMode === 'split' && entries.length > 0) { const currentIndex = entries.findIndex((item) => item.id === selectedEntryId); if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); const nextIndex = Math.min(currentIndex + 1, entries.length - 1); if (entries[nextIndex]) { const nextEntry = entries[nextIndex]; setSelectedEntryId(nextEntry.id); if (!nextEntry.is_read) { handleToggleRead(nextEntry); } } } else if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); const prevIndex = Math.max(currentIndex - 1, 0); if (entries[prevIndex]) { const prevEntry = entries[prevIndex]; setSelectedEntryId(prevEntry.id); if (!prevEntry.is_read) { handleToggleRead(prevEntry); } } } else if (e.key === 's') { e.preventDefault(); const currentEntry = entries[currentIndex]; if (currentEntry) handleToggleStar(currentEntry); } else if (e.key === 'm') { e.preventDefault(); const currentEntry = entries[currentIndex]; if (currentEntry) handleToggleRead(currentEntry); } else if (e.key === 'o') { const currentEntry = entries[currentIndex]; if (currentEntry?.url) { window.open(currentEntry.url, '_blank'); } } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [viewMode, entries, selectedEntryId, isRefreshing, lastContentMode]); if (authLoading) { return (
Loading RSSPipe...
); } if (!currentUser) { return setCurrentUser(u)} />; } return (
{/* Top Header is rendered in 3-Split desktop view */} {viewMode === 'split' && (
setFilter((prev) => ({ ...prev, search }))} isRefreshing={isRefreshing} onRefreshAll={handleRefreshAll} onOpenAddFeed={() => setShowAddFeedModal(true)} onOpenSettings={() => setViewMode('settings')} onOpenShortcuts={() => setShowShortcutsModal(true)} onLogout={async () => { await api.logout(); setCurrentUser(null); }} /> )} {/* Main View Router */} {viewMode === 'split' ? ( setFilter((prev) => ({ ...prev, ...newFilter }))} categories={categories} feeds={feeds} entries={entries} selectedEntryId={selectedEntryId} onSelectEntry={(entry) => { setSelectedEntryId(entry.id); if (!entry.is_read) { handleToggleRead(entry); } }} onToggleStar={handleToggleStar} onToggleRead={handleToggleRead} onMarkAllRead={handleMarkAllRead} onUpdateEntry={handleUpdateEntry} stats={stats} totalEntries={totalEntries} onOpenSettings={() => setViewMode('settings')} onOpenAddFeed={() => setShowAddFeedModal(true)} onPlayAudio={handlePlayAudio} currentAudioUrl={currentAudioUrl} isPlayingAudio={isPlayingAudio} /> ) : viewMode === 'waterfall' ? ( setFilter((prev) => ({ ...prev, ...newFilter }))} categories={categories} feeds={feeds} entries={entries} stats={stats} currentUser={currentUser} onToggleRead={handleToggleRead} onToggleStar={handleToggleStar} onUpdateEntry={handleUpdateEntry} onRefreshData={() => { loadSidebarData(); loadEntries(); }} onOpenAddFeed={() => setShowAddFeedModal(true)} onPlayAudio={handlePlayAudio} currentAudioUrl={currentAudioUrl} isPlayingAudio={isPlayingAudio} onTogglePlayAudio={handleTogglePlayAudio} onCloseAudio={handleCloseAudio} playingEntry={playingEntry} viewPreference={viewPreference} onChangeViewPreference={handleChangeViewPreference} onSwitchToDesktop={() => setViewMode('split')} hasMore={entries.length < totalEntries} isLoadingMore={isLoadingMore} onLoadMore={handleLoadMore} /> ) : ( { loadSidebarData(); loadEntries(); }} onOpenAddFeed={() => setShowAddFeedModal(true)} onBack={() => setViewMode(lastContentMode)} viewPreference={viewPreference} onChangeViewPreference={handleChangeViewPreference} /> )} {/* Shared Modal Dialogs */} {showAddFeedModal && ( setShowAddFeedModal(false)} onFeedAdded={() => { loadSidebarData(); loadEntries(); }} /> )} {showOPMLModal && ( setShowOPMLModal(false)} onImportComplete={() => { loadSidebarData(); loadEntries(); }} /> )} {showShortcutsModal && ( setShowShortcutsModal(false)} /> )}
); };