513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
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<User | null>(null);
|
|
const [authLoading, setAuthLoading] = useState(true);
|
|
|
|
// App Filter State with Persistence
|
|
const [filter, setFilter] = useState<FilterState>(() => {
|
|
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<ViewPreference>(() => {
|
|
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<ViewMode>(() => {
|
|
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<string | null>(() => {
|
|
return localStorage.getItem('rsspipe_selected_entry_id');
|
|
});
|
|
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [feeds, setFeeds] = useState<Feed[]>([]);
|
|
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
|
const [stats, setStats] = useState<Stats | null>(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<string | null>(null);
|
|
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
|
const [playingEntry, setPlayingEntry] = useState<FeedEntry | null>(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);
|
|
} 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 (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]);
|
|
|
|
// 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: 200,
|
|
});
|
|
setEntries(res.entries || []);
|
|
|
|
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]);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// Update Single Entry Action
|
|
const handleUpdateEntry = (updated: FeedEntry) => {
|
|
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : 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 (
|
|
<div style={{ display: 'flex', height: '100vh', width: '100vw', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-app)' }}>
|
|
<div style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-muted)' }}>
|
|
Loading RSSPipe...
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!currentUser) {
|
|
return <LoginView onLoginSuccess={(u) => setCurrentUser(u)} />;
|
|
}
|
|
|
|
return (
|
|
<div className="app-container">
|
|
{/* Top Header is rendered in 3-Split desktop view */}
|
|
{viewMode === 'split' && (
|
|
<Header
|
|
search={filter.search}
|
|
onSearchChange={(search) => 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' ? (
|
|
<ThreePaneView
|
|
filter={filter}
|
|
onFilterChange={(newFilter) => 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}
|
|
onOpenSettings={() => setViewMode('settings')}
|
|
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
|
onPlayAudio={handlePlayAudio}
|
|
currentAudioUrl={currentAudioUrl}
|
|
isPlayingAudio={isPlayingAudio}
|
|
/>
|
|
) : viewMode === 'waterfall' ? (
|
|
<WaterfallView
|
|
filter={filter}
|
|
onFilterChange={(newFilter) => 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')}
|
|
/>
|
|
) : (
|
|
<SettingsView
|
|
currentUser={currentUser}
|
|
feeds={feeds}
|
|
categories={categories}
|
|
stats={stats}
|
|
onRefreshData={() => {
|
|
loadSidebarData();
|
|
loadEntries();
|
|
}}
|
|
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
|
onBack={() => setViewMode(lastContentMode)}
|
|
viewPreference={viewPreference}
|
|
onChangeViewPreference={handleChangeViewPreference}
|
|
/>
|
|
)}
|
|
|
|
{/* Shared Modal Dialogs */}
|
|
{showAddFeedModal && (
|
|
<AddFeedModal
|
|
categories={categories}
|
|
onClose={() => setShowAddFeedModal(false)}
|
|
onFeedAdded={() => {
|
|
loadSidebarData();
|
|
loadEntries();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showOPMLModal && (
|
|
<OPMLModal
|
|
onClose={() => setShowOPMLModal(false)}
|
|
onImportComplete={() => {
|
|
loadSidebarData();
|
|
loadEntries();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showShortcutsModal && (
|
|
<KeyboardShortcutsModal onClose={() => setShowShortcutsModal(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|