feat: scaffold initial RSS reader project with Go backend and React frontend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user