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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { X, UploadCloud, Download, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface OPMLModalProps {
|
||||
onClose: () => void;
|
||||
onImportComplete: () => void;
|
||||
}
|
||||
|
||||
export const OPMLModal: React.FC<OPMLModalProps> = ({
|
||||
onClose,
|
||||
onImportComplete,
|
||||
}) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{ imported: number; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setFile(e.target.files[0]);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
setFile(e.dataTransfer.files[0]);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await api.importOPML(file);
|
||||
setResult({ imported: res.imported_count, total: res.total_parsed });
|
||||
onImportComplete();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to import OPML file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = api.exportOPMLUrl();
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>OPML Migration & Backup</h3>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(52, 211, 153, 0.15)',
|
||||
border: '1px solid var(--success)',
|
||||
color: 'var(--success)',
|
||||
padding: '0.85rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
marginBottom: '1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
<span>
|
||||
Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated!
|
||||
</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>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--bg-main)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".opml,.xml"
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" /> Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Import {file.name}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1.25rem' }}>
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, 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.
|
||||
</p>
|
||||
<button className="btn-secondary" onClick={handleExport}>
|
||||
<Download size={16} /> Export OPML File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,801 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, Stats, User } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Rss,
|
||||
Folder,
|
||||
FileSpreadsheet,
|
||||
Shield,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
Download,
|
||||
Loader2,
|
||||
Edit2,
|
||||
AlertCircle,
|
||||
X,
|
||||
Clock,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SettingsViewProps {
|
||||
currentUser: User;
|
||||
feeds: Feed[];
|
||||
categories: Category[];
|
||||
stats: Stats | null;
|
||||
onRefreshData: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
currentUser,
|
||||
feeds,
|
||||
categories,
|
||||
stats,
|
||||
onRefreshData,
|
||||
onOpenAddFeed,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'opml' | 'account'>('feeds');
|
||||
const [feedSearch, setFeedSearch] = useState('');
|
||||
const [newCatTitle, setNewCatTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Edit Feed Modal State
|
||||
const [editingFeed, setEditingFeed] = useState<Feed | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editCategoryId, setEditCategoryId] = useState<string>('');
|
||||
const [editInterval, setEditInterval] = useState<number>(30);
|
||||
const [editTimeout, setEditTimeout] = useState<number>(10);
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
// Debug Log Modal State
|
||||
const [debugFeed, setDebugFeed] = useState<Feed | null>(null);
|
||||
|
||||
// Custom Delete Confirmation Modal State
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
type: 'feed' | 'category';
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
// OPML state
|
||||
const [opmlFile, setOpmlFile] = useState<File | null>(null);
|
||||
const [opmlLoading, setOpmlLoading] = useState(false);
|
||||
const [opmlResult, setOpmlResult] = useState<string | null>(null);
|
||||
|
||||
// Filter feeds
|
||||
const filteredFeeds = feeds.filter(
|
||||
(f) =>
|
||||
f.title.toLowerCase().includes(feedSearch.toLowerCase()) ||
|
||||
f.feed_url.toLowerCase().includes(feedSearch.toLowerCase()) ||
|
||||
(f.category_title && f.category_title.toLowerCase().includes(feedSearch.toLowerCase()))
|
||||
);
|
||||
|
||||
const openEditModal = (feed: Feed) => {
|
||||
setEditingFeed(feed);
|
||||
setEditTitle(feed.title);
|
||||
setEditCategoryId(feed.category_id || '');
|
||||
setEditInterval(feed.refresh_interval_minutes || 30);
|
||||
setEditTimeout(feed.timeout_seconds || 10);
|
||||
};
|
||||
|
||||
const handleSaveFeed = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingFeed) return;
|
||||
setEditSaving(true);
|
||||
try {
|
||||
await api.updateFeed(editingFeed.id, {
|
||||
title: editTitle.trim() || undefined,
|
||||
category_id: editCategoryId || '',
|
||||
refresh_interval_minutes: Number(editInterval),
|
||||
timeout_seconds: Number(editTimeout),
|
||||
});
|
||||
setEditingFeed(null);
|
||||
onRefreshData();
|
||||
setMessage(`Updated "${editTitle || editingFeed.title}" settings.`);
|
||||
setTimeout(() => setMessage(null), 3500);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to update feed');
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshFeed = async (feedId: string) => {
|
||||
try {
|
||||
await api.refreshFeed(feedId);
|
||||
onRefreshData();
|
||||
setMessage('Feed refreshed successfully.');
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to refresh feed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
try {
|
||||
if (deleteConfirm.type === 'feed') {
|
||||
await api.deleteFeed(deleteConfirm.id);
|
||||
setMessage(`Unsubscribed from "${deleteConfirm.title}".`);
|
||||
} else {
|
||||
await api.deleteCategory(deleteConfirm.id);
|
||||
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
||||
}
|
||||
onRefreshData();
|
||||
setTimeout(() => setMessage(null), 3500);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Delete operation failed');
|
||||
} finally {
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCatTitle.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.createCategory(newCatTitle.trim());
|
||||
setNewCatTitle('');
|
||||
onRefreshData();
|
||||
setMessage('Category created.');
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create category');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportOPML = async () => {
|
||||
if (!opmlFile) return;
|
||||
setOpmlLoading(true);
|
||||
setOpmlResult(null);
|
||||
try {
|
||||
const res = await api.importOPML(opmlFile);
|
||||
setOpmlResult(`Successfully imported ${res.imported_count} new feeds (out of ${res.total_parsed} parsed).`);
|
||||
onRefreshData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to import OPML');
|
||||
} finally {
|
||||
setOpmlLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportOPML = () => {
|
||||
window.open(api.exportOPMLUrl(), '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
{/* Settings Tab Navigation (Responsive sidebar on desktop / top chips on mobile) */}
|
||||
<div className="settings-nav">
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'feeds' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('feeds')}
|
||||
>
|
||||
<Rss size={15} />
|
||||
<span>Subscriptions ({feeds.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'categories' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('categories')}
|
||||
>
|
||||
<Folder size={15} />
|
||||
<span>Categories ({categories.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'opml' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('opml')}
|
||||
>
|
||||
<FileSpreadsheet size={15} />
|
||||
<span>OPML Migration</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'account' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('account')}
|
||||
>
|
||||
<Shield size={15} />
|
||||
<span>Account & System</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Settings Content Area */}
|
||||
<div className="settings-content-area">
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
background: '#f0fdf4',
|
||||
border: '1px solid #bbf7d0',
|
||||
color: 'var(--success)',
|
||||
padding: '0.65rem 1rem',
|
||||
borderRadius: 6,
|
||||
marginBottom: '1.25rem',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Subscriptions Management Tab */}
|
||||
{activeTab === 'feeds' && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Subscription Management
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Configure background pulling interval, request timeouts, and categories per feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={onOpenAddFeed}>
|
||||
<Plus size={14} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-card" style={{ padding: '0.85rem' }}>
|
||||
<div style={{ position: 'relative', marginBottom: '0.85rem' }}>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0.75rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '2.2rem' }}
|
||||
placeholder="Filter by title, URL or category..."
|
||||
value={feedSearch}
|
||||
onChange={(e) => setFeedSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left', color: 'var(--text-muted)' }}>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>FEED</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>CATEGORY</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>SCHEDULE</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>STATUS</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem', textAlign: 'right' }}>ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFeeds.map((feed) => (
|
||||
<tr key={feed.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||||
<td style={{ padding: '0.65rem 0.65rem', maxWidth: 260 }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{feed.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{feed.feed_url}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
||||
{feed.category_title ? (
|
||||
<span className="tag-badge">{feed.category_title}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-dim)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: '0.72rem', color: 'var(--text-secondary)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Clock size={11} /> {feed.refresh_interval_minutes || 30}m
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--text-muted)' }}>
|
||||
<Timer size={11} /> {feed.timeout_seconds || 10}s
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
||||
{feed.fetch_error ? (
|
||||
<button
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: 'var(--danger)',
|
||||
borderRadius: 4,
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
}}
|
||||
onClick={() => setDebugFeed(feed)}
|
||||
title="Click to view error details"
|
||||
>
|
||||
<AlertCircle size={11} /> Error Logs
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: 'var(--success)', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||
● Healthy
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 3 }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28 }}
|
||||
title="Edit schedule and settings"
|
||||
onClick={() => openEditModal(feed)}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28 }}
|
||||
title="Fetch Now"
|
||||
onClick={() => handleRefreshFeed(feed.id)}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28, color: 'var(--danger)' }}
|
||||
title="Unsubscribe"
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
type: 'feed',
|
||||
id: feed.id,
|
||||
title: feed.title,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Categories Tab */}
|
||||
{activeTab === 'categories' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Category Management
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Create, organize, and delete category folders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Category */}
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Add New Category</h3>
|
||||
<form onSubmit={handleCreateCategory} style={{ display: 'flex', gap: '0.6rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Category name (e.g. AI Research, Finance)..."
|
||||
value={newCatTitle}
|
||||
onChange={(e) => setNewCatTitle(e.target.value)}
|
||||
style={{ maxWidth: 360 }}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
<Plus size={14} />
|
||||
<span>Create Category</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Existing Categories */}
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Existing Categories ({categories.length})</h3>
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.65rem 0',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem' }}>
|
||||
<Folder size={15} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontWeight: 600, color: 'var(--text-main)' }}>{cat.title}</span>
|
||||
<span className="badge">{cat.feed_count || 0} feeds</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
type: 'category',
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
})
|
||||
}
|
||||
title="Delete Category"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. OPML Tab */}
|
||||
{activeTab === 'opml' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
OPML Migration & Backup
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Easily import from external RSS services or download a complete backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{opmlResult && (
|
||||
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: 'var(--success)', padding: '0.85rem 1rem', borderRadius: 6, marginBottom: '1.25rem', fontSize: '0.85rem' }}>
|
||||
{opmlResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Import Subscriptions</h3>
|
||||
<p className="settings-card-subtitle">
|
||||
Select an OPML XML file exported from Miniflux, Feedly, or NetNewsWire.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed var(--border-dark)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
background: 'var(--bg-app)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
onClick={() => document.getElementById('settings-opml-file')?.click()}
|
||||
>
|
||||
<input
|
||||
id="settings-opml-file"
|
||||
type="file"
|
||||
accept=".opml,.xml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setOpmlFile(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<UploadCloud size={30} style={{ color: 'var(--text-muted)', margin: '0 auto 0.5rem auto' }} />
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
|
||||
{opmlFile ? opmlFile.name : 'Click to select OPML file'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opmlFile && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImportOPML}
|
||||
disabled={opmlLoading}
|
||||
>
|
||||
{opmlLoading ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Import {opmlFile.name}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Export Subscriptions</h3>
|
||||
<p className="settings-card-subtitle">
|
||||
Download an OPML 2.0 XML file containing all your {feeds.length} subscribed feeds and folder trees.
|
||||
</p>
|
||||
|
||||
<button className="btn-secondary" onClick={handleExportOPML}>
|
||||
<Download size={14} />
|
||||
<span>Export OPML Backup</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. Account & System Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Account & System Health
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Single-user gateway credentials and storage metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Admin Profile</h3>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Username:</strong> {currentUser.username}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>User ID:</strong> <code>{currentUser.id}</code>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Access Mode:</strong> Single-User RSS Information Gateway
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Storage & Engine Stats</h3>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Database Engine:</strong> SQLite 3 with WAL (Write-Ahead Logging) Mode
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Total Subscribed Feeds:</strong> {stats?.total_feeds || feeds.length}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Total Unread Articles:</strong> {stats?.total_unread || 0}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Total Starred Articles:</strong> {stats?.total_starred || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Feed Modal (Pulling interval, timeout, category, title) */}
|
||||
{editingFeed && (
|
||||
<div className="modal-overlay" onClick={() => setEditingFeed(null)}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edit Subscription Settings</h3>
|
||||
<button className="btn-icon" onClick={() => setEditingFeed(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveFeed}>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Feed Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Feed URL</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editingFeed.feed_url}
|
||||
disabled
|
||||
style={{ background: 'var(--bg-app)', color: 'var(--text-muted)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Category</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editCategoryId}
|
||||
onChange={(e) => setEditCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">(Uncategorized)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Pull Interval</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editInterval}
|
||||
onChange={(e) => setEditInterval(Number(e.target.value))}
|
||||
>
|
||||
<option value={5}>Every 5 minutes</option>
|
||||
<option value={15}>Every 15 minutes</option>
|
||||
<option value={30}>Every 30 minutes</option>
|
||||
<option value={60}>Every 1 hour</option>
|
||||
<option value={120}>Every 2 hours</option>
|
||||
<option value={360}>Every 6 hours</option>
|
||||
<option value={1440}>Every 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Request Timeout</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editTimeout}
|
||||
onChange={(e) => setEditTimeout(Number(e.target.value))}
|
||||
>
|
||||
<option value={5}>5 seconds (Fast)</option>
|
||||
<option value={10}>10 seconds (Standard)</option>
|
||||
<option value={15}>15 seconds</option>
|
||||
<option value={30}>30 seconds (Slow)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditingFeed(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={editSaving}>
|
||||
{editSaving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Logs Modal (Persistent error inspector) */}
|
||||
{debugFeed && (
|
||||
<div className="modal-overlay" onClick={() => setDebugFeed(null)}>
|
||||
<div className="modal-card" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<AlertCircle size={17} style={{ color: 'var(--danger)' }} />
|
||||
<h3>Feed Fetch Diagnostics</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setDebugFeed(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>Feed:</strong> {debugFeed.title}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)', wordBreak: 'break-all' }}>
|
||||
<strong>URL:</strong> {debugFeed.feed_url}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<label className="form-label">Error Log Output</label>
|
||||
<pre
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: 'var(--danger)',
|
||||
padding: '0.85rem',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.78rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{debugFeed.fetch_error || 'No error recorded.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
handleRefreshFeed(debugFeed.id);
|
||||
setDebugFeed(null);
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>Retry Fetch Now</span>
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={() => setDebugFeed(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solid Confirmation Modal for Deletion (Never auto-dismisses) */}
|
||||
{deleteConfirm && (
|
||||
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
|
||||
<div className="modal-card" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 style={{ color: 'var(--danger)' }}>Confirm Deletion</h3>
|
||||
<button className="btn-icon" onClick={() => setDeleteConfirm(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ fontSize: '0.85rem', lineHeight: 1.5 }}>
|
||||
Are you sure you want to {deleteConfirm.type === 'feed' ? 'unsubscribe from' : 'delete category'}{' '}
|
||||
<strong>"{deleteConfirm.title}"</strong>?
|
||||
{deleteConfirm.type === 'category' && (
|
||||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)', fontSize: '0.78rem' }}>
|
||||
Feeds inside this category will remain and become uncategorized.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button className="btn-secondary" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
CheckCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ThreePaneViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
stats: Stats | null;
|
||||
entries: FeedEntry[];
|
||||
selectedIndex: number;
|
||||
onSelectEntry: (index: number) => void;
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onMarkAllRead: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
stats,
|
||||
entries,
|
||||
selectedIndex,
|
||||
onSelectEntry,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onMarkAllRead,
|
||||
sidebarCollapsed,
|
||||
}) => {
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedEntry = entries[selectedIndex] || entries[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
||||
const el = cardRefs.current[selectedIndex];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, entries.length]);
|
||||
|
||||
// Robust Auto-Read Detection (Dwell Time for short posts + IntersectionObserver for end of post + Scroll Progress)
|
||||
useEffect(() => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
|
||||
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Check if the content fits entirely in the viewport (short article)
|
||||
const checkViewportFit = () => {
|
||||
if (readerPaneRef.current) {
|
||||
const { scrollHeight, clientHeight } = readerPaneRef.current;
|
||||
// If article is short (fits or almost fits), mark as read after 1.5s of reading
|
||||
if (scrollHeight <= clientHeight + 60) {
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
// For longer articles, if user stays focused for 5 seconds, also auto-read
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to allow DOM render calculation
|
||||
const timeout = setTimeout(checkViewportFit, 100);
|
||||
|
||||
// IntersectionObserver to observe bottom of article
|
||||
let observer: IntersectionObserver | null = null;
|
||||
if (endSentinelRef.current && readerPaneRef.current) {
|
||||
observer = new IntersectionObserver(
|
||||
(entriesObs) => {
|
||||
entriesObs.forEach((entryObs) => {
|
||||
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: readerPaneRef.current,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
observer.observe(endSentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (dwellTimer) clearTimeout(dwellTimer);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
||||
|
||||
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
||||
if (scrollProgress >= 0.85) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
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>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane 3: Distraction-Free Reading Canvas */}
|
||||
<div className="reader-pane" ref={readerPaneRef} onScroll={handleReaderScroll}>
|
||||
{selectedEntry ? (
|
||||
<>
|
||||
{/* Top Toolbar */}
|
||||
<div className="reader-toolbar">
|
||||
<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>
|
||||
</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="reader-source-bar">
|
||||
<span className="tag-badge">
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{' '}
|
||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* End of article sentinel for auto-read observer */}
|
||||
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,391 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Category, FeedEntry, FilterState } from '../types';
|
||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
Inbox,
|
||||
Star as StarIcon,
|
||||
Layers,
|
||||
Rss,
|
||||
ArrowLeft,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
} 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;
|
||||
}
|
||||
|
||||
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
entries,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
}) => {
|
||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCardClick = (entry: FeedEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
};
|
||||
|
||||
// Robust Auto-Read Detection in Waterfall Full-Screen Reader
|
||||
useEffect(() => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
|
||||
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const checkViewportFit = () => {
|
||||
if (readerScrollRef.current) {
|
||||
const { scrollHeight, clientHeight } = readerScrollRef.current;
|
||||
// Short article: auto mark as read after 1.5s
|
||||
if (scrollHeight <= clientHeight + 60) {
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
// Longer article: auto mark read after 5s active viewing
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(checkViewportFit, 100);
|
||||
|
||||
// IntersectionObserver on bottom sentinel
|
||||
let observer: IntersectionObserver | null = null;
|
||||
if (endSentinelRef.current && readerScrollRef.current) {
|
||||
observer = new IntersectionObserver(
|
||||
(entriesObs) => {
|
||||
entriesObs.forEach((entryObs) => {
|
||||
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: readerScrollRef.current,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
observer.observe(endSentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (dwellTimer) clearTimeout(dwellTimer);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
||||
|
||||
const handlePostScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
||||
if (scrollProgress >= 0.85) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
};
|
||||
|
||||
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>}
|
||||
|
||||
{/* 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: 4 }}>
|
||||
<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} onScroll={handlePostScroll}>
|
||||
<div className="reader-content-canvas" style={{ padding: 0 }}>
|
||||
<div className="reader-source-bar">
|
||||
<span className="tag-badge">{selectedEntry.feed_title}</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{' '}
|
||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* End of article sentinel for auto-read observer */}
|
||||
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1132
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './index.css'
|
||||
|
||||
// Register Service Worker for PWA
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||
console.warn('Service Worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Category, Feed, FeedEntry, Stats, User } from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
if (res.status === 401 && path !== '/auth/login') {
|
||||
localStorage.removeItem('rsspipe_jwt');
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Session expired or unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let errMsg = `Request failed: ${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const errJson = await res.json();
|
||||
if (errJson.error) errMsg = errJson.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
async login(username: string, password: string): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.token) {
|
||||
localStorage.setItem('rsspipe_jwt', res.token);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request('/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
localStorage.removeItem('rsspipe_jwt');
|
||||
}
|
||||
},
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return request<User>('/auth/me');
|
||||
},
|
||||
|
||||
// Stats
|
||||
async getStats(): Promise<Stats> {
|
||||
return request<Stats>('/stats');
|
||||
},
|
||||
|
||||
// Categories
|
||||
async listCategories(): Promise<Category[]> {
|
||||
return request<Category[]>('/categories');
|
||||
},
|
||||
|
||||
async createCategory(title: string): Promise<Category> {
|
||||
return request<Category>('/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
return request<void>(`/categories/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// Feeds
|
||||
async listFeeds(): Promise<Feed[]> {
|
||||
return request<Feed[]>('/feeds');
|
||||
},
|
||||
|
||||
async createFeed(feed_url: string, category_id?: string, title?: string): Promise<Feed> {
|
||||
return request<Feed>('/feeds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_url, category_id, title }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateFeed(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
refresh_interval_minutes?: number;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
): Promise<Feed> {
|
||||
return request<Feed>(`/feeds/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteFeed(id: string): Promise<void> {
|
||||
return request<void>(`/feeds/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async refreshFeed(id: string): Promise<Feed> {
|
||||
return request<Feed>(`/feeds/${id}/refresh`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async refreshAllFeeds(): Promise<void> {
|
||||
return request<void>('/feeds/refresh-all', { method: 'POST' });
|
||||
},
|
||||
|
||||
// Entries
|
||||
async listEntries(params: {
|
||||
view?: string;
|
||||
feed_id?: string;
|
||||
category_id?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ entries: FeedEntry[]; total: number; limit: number; offset: number }> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.view) query.set('view', params.view);
|
||||
if (params.feed_id) query.set('feed_id', params.feed_id);
|
||||
if (params.category_id) query.set('category_id', params.category_id);
|
||||
if (params.search) query.set('search', params.search);
|
||||
if (params.limit) query.set('limit', params.limit.toString());
|
||||
if (params.offset) query.set('offset', params.offset.toString());
|
||||
|
||||
return request(`/entries?${query.toString()}`);
|
||||
},
|
||||
|
||||
async updateRead(id: string, is_read: boolean): Promise<void> {
|
||||
return request(`/entries/${id}/read`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_read }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateStar(id: string, is_starred: boolean): Promise<void> {
|
||||
return request(`/entries/${id}/star`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_starred }),
|
||||
});
|
||||
},
|
||||
|
||||
async markAllRead(feed_id?: string, category_id?: string): Promise<void> {
|
||||
return request('/entries/mark-all-read', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_id, category_id }),
|
||||
});
|
||||
},
|
||||
|
||||
// OPML
|
||||
async importOPML(file: File): Promise<{ imported_count: number; total_parsed: number }> {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}/opml/import`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to import OPML');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
|
||||
exportOPMLUrl(): string {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
return `${API_BASE}/opml/export?token=${token || ''}`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
title: string;
|
||||
parent_id?: string;
|
||||
sort_order: number;
|
||||
feed_count?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string;
|
||||
title: string;
|
||||
feed_url: string;
|
||||
site_url?: string;
|
||||
category_id?: string;
|
||||
category_title?: string;
|
||||
is_virtual_digest: boolean;
|
||||
refresh_interval_minutes: number;
|
||||
timeout_seconds: number;
|
||||
etag?: string;
|
||||
last_modified?: string;
|
||||
last_fetched_at?: string;
|
||||
fetch_error?: string;
|
||||
unread_count: number;
|
||||
total_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FeedEntry {
|
||||
id: string;
|
||||
feed_id: string;
|
||||
feed_title: string;
|
||||
feed_site_url?: string;
|
||||
guid: string;
|
||||
url: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
content: string;
|
||||
raw_content?: string;
|
||||
published_at: string;
|
||||
is_read: boolean;
|
||||
is_starred: boolean;
|
||||
share_uuid?: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_feeds: number;
|
||||
total_unread: number;
|
||||
total_starred: number;
|
||||
total_categories: number;
|
||||
}
|
||||
|
||||
export type ViewType = 'inbox' | 'starred' | 'all';
|
||||
export type ViewMode = 'split' | 'waterfall' | 'settings';
|
||||
|
||||
export interface FilterState {
|
||||
view: ViewType;
|
||||
categoryId?: string;
|
||||
feedId?: string;
|
||||
search: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utility functions for RSSPipe UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts the first image URL from HTML content for thumbnail previews (Xiaohongshu style)
|
||||
*/
|
||||
export function extractFirstImage(html?: string): string | null {
|
||||
if (!html) return null;
|
||||
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
if (match && match[1]) {
|
||||
const src = match[1];
|
||||
// Ignore tracking pixels, 1x1 gifs or tiny icons
|
||||
if (src.includes('feedads') || src.includes('stat?') || src.includes('pixel') || src.endsWith('.svg')) {
|
||||
return null;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML tags and clean up text for previews
|
||||
*/
|
||||
export function stripHtml(html?: string, maxLength = 140): string {
|
||||
if (!html) return '';
|
||||
const text = html.replace(/<[^>]*>?/gm, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative date (e.g. 5m ago, 2h ago, May 14)
|
||||
*/
|
||||
export function formatRelativeDate(dateStr: string): string {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate reading time in minutes
|
||||
*/
|
||||
export function estimateReadingTime(content?: string): string {
|
||||
if (!content) return '1 min';
|
||||
const text = stripHtml(content, 99999);
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
const mins = Math.ceil(words / 200);
|
||||
return `${mins} min read`;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user