feat: scaffold initial RSS reader project with Go backend and React frontend
This commit is contained in:
+518
@@ -0,0 +1,518 @@
|
||||
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<User | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
// App State with localStorage 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: '' };
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
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<boolean>(() => {
|
||||
return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true';
|
||||
});
|
||||
|
||||
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 [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 with persisted selectedEntryId
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
if (selectedEntryId) {
|
||||
const idx = entries.findIndex((e) => e.id === selectedEntryId);
|
||||
if (idx >= 0) {
|
||||
setFocusedIndex(idx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setFocusedIndex(0);
|
||||
if (entries[0]) {
|
||||
setSelectedEntryId(entries[0].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||
}
|
||||
}
|
||||
}, [entries, selectedEntryId]);
|
||||
|
||||
const handleSelectEntry = (index: number) => {
|
||||
setFocusedIndex(index);
|
||||
if (entries[index]) {
|
||||
setSelectedEntryId(entries[index].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-Refresh & Background Polling (Every 30s + on focus / visibility change)
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}, 30000);
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [currentUser, loadSidebarData, loadEntries]);
|
||||
|
||||
// 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
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
)
|
||||
);
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark All Read
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await api.markAllRead(filter.feedId, filter.categoryId);
|
||||
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
|
||||
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();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
||||
if (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();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0);
|
||||
if (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
|
||||
if (entries[focusedIndex]?.url) {
|
||||
e.preventDefault();
|
||||
window.open(entries[focusedIndex].url, '_blank');
|
||||
}
|
||||
break;
|
||||
|
||||
case 's':
|
||||
case 'S': // Toggle Star
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleToggleStar(entries[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
case 'M': // Toggle Read / Unread
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleToggleRead(entries[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
case 'E': // Archive / Mark read and advance
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
if (!entries[focusedIndex].is_read) {
|
||||
handleToggleRead(entries[focusedIndex]);
|
||||
}
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
||||
if (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]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div style={{ color: 'var(--text-main)', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
Loading RSSPipe...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginView onLoginSuccess={(user) => setCurrentUser(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* App Header */}
|
||||
<Header
|
||||
search={filter.search}
|
||||
onSearchChange={(val) => 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' ? (
|
||||
<ThreePaneView
|
||||
filter={filter}
|
||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
stats={stats}
|
||||
entries={entries}
|
||||
selectedIndex={focusedIndex}
|
||||
onSelectEntry={handleSelectEntry}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
) : viewMode === 'waterfall' ? (
|
||||
<WaterfallView
|
||||
filter={filter}
|
||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
||||
categories={categories}
|
||||
entries={entries}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
/>
|
||||
) : (
|
||||
<SettingsView
|
||||
currentUser={currentUser}
|
||||
feeds={feeds}
|
||||
categories={categories}
|
||||
stats={stats}
|
||||
onRefreshData={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showAddFeedModal && (
|
||||
<AddFeedModal
|
||||
categories={categories}
|
||||
onClose={() => setShowAddFeedModal(false)}
|
||||
onFeedAdded={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOPMLModal && (
|
||||
<OPMLModal
|
||||
onClose={() => setShowOPMLModal(false)}
|
||||
onImportComplete={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showShortcutsModal && (
|
||||
<KeyboardShortcutsModal onClose={() => setShowShortcutsModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user