feat: implement rule engine, worker pool, event bus, and readability scraping with enhanced logging and content sanitization.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, Stats, User } from '../types';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Category, Feed, Rule, Stats, User } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Rss,
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Eye,
|
||||
Lock,
|
||||
Key,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SettingsViewProps {
|
||||
@@ -41,7 +43,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
onRefreshData,
|
||||
onOpenAddFeed,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'viewer' | 'opml' | 'account'>('feeds');
|
||||
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'viewer' | 'opml' | 'account'>('feeds');
|
||||
const [autoReadBottom, setAutoReadBottom] = useState<boolean>(() => {
|
||||
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
||||
});
|
||||
@@ -50,6 +52,18 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Rules State
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(false);
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
const [ruleName, setRuleName] = useState('');
|
||||
const [ruleTrigger, setRuleTrigger] = useState('on_post_created');
|
||||
const [ruleMatchMode, setRuleMatchMode] = useState('all');
|
||||
const [ruleField, setRuleField] = useState('feed_id');
|
||||
const [ruleOp, setRuleOp] = useState('eq');
|
||||
const [ruleVal, setRuleVal] = useState('');
|
||||
const [ruleActionType, setRuleActionType] = useState('readability_scrape');
|
||||
|
||||
// Edit Feed Modal State
|
||||
const [editingFeed, setEditingFeed] = useState<Feed | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
@@ -110,6 +124,78 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, []);
|
||||
|
||||
const loadRules = async () => {
|
||||
setRulesLoading(true);
|
||||
try {
|
||||
const data = await api.listRules();
|
||||
setRules(data || []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ruleName.trim()) return;
|
||||
try {
|
||||
await api.createRule({
|
||||
name: ruleName.trim(),
|
||||
trigger: ruleTrigger,
|
||||
match_mode: ruleMatchMode,
|
||||
conditions: [
|
||||
{
|
||||
field: ruleField,
|
||||
operator: ruleOp,
|
||||
value: ruleVal.trim(),
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: ruleActionType,
|
||||
},
|
||||
],
|
||||
});
|
||||
setShowRuleModal(false);
|
||||
setRuleName('');
|
||||
setRuleVal('');
|
||||
loadRules();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create rule');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRuleActive = async (rule: Rule) => {
|
||||
try {
|
||||
await api.updateRule(rule.id, {
|
||||
name: rule.name,
|
||||
trigger: rule.trigger,
|
||||
match_mode: rule.match_mode,
|
||||
conditions: rule.conditions,
|
||||
actions: rule.actions,
|
||||
is_active: !rule.is_active,
|
||||
});
|
||||
loadRules();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to toggle rule');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this rule?')) return;
|
||||
try {
|
||||
await api.deleteRule(id);
|
||||
loadRules();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete rule');
|
||||
}
|
||||
};
|
||||
|
||||
// Filter feeds
|
||||
const filteredFeeds = feeds.filter(
|
||||
(f) =>
|
||||
@@ -148,14 +234,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshFeed = async (feedId: string) => {
|
||||
const handleCreateCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCatTitle.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.refreshFeed(feedId);
|
||||
await api.createCategory(newCatTitle.trim());
|
||||
setNewCatTitle('');
|
||||
onRefreshData();
|
||||
setMessage('Feed refreshed successfully.');
|
||||
setMessage(`Category "${newCatTitle}" created.`);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to refresh feed');
|
||||
alert(err.message || 'Failed to create category');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,28 +262,22 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
||||
}
|
||||
onRefreshData();
|
||||
setTimeout(() => setMessage(null), 3500);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Delete operation failed');
|
||||
alert(err.message || 'Failed to delete');
|
||||
} finally {
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCatTitle.trim()) return;
|
||||
setLoading(true);
|
||||
const handleRefreshFeed = async (feedId: string) => {
|
||||
try {
|
||||
await api.createCategory(newCatTitle.trim());
|
||||
setNewCatTitle('');
|
||||
await api.refreshFeed(feedId);
|
||||
onRefreshData();
|
||||
setMessage('Category created.');
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
setMessage('Feed refreshed successfully.');
|
||||
setTimeout(() => setMessage(null), 2500);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create category');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
alert(err.message || 'Failed to refresh feed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,6 +320,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
<span>Categories ({categories.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'rules' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('rules')}
|
||||
>
|
||||
<Sparkles size={15} />
|
||||
<span>Rules & Hooks ({rules.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'viewer' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('viewer')}
|
||||
@@ -514,6 +608,118 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules & Automation Hooks Tab */}
|
||||
{activeTab === 'rules' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Automation Rules & Pipeline Hooks
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Filter feeds and automatically trigger enrichments (Readability Scrape, Star, Read) on post arrival or variant updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={() => setShowRuleModal(true)}>
|
||||
<Plus size={14} />
|
||||
<span>Create Rule</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rulesLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-muted)' }}>
|
||||
<Loader2 size={24} className="spin" style={{ margin: '0 auto 0.5rem auto' }} />
|
||||
<p>Loading rules...</p>
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="settings-card" style={{ textAlign: 'center', padding: '3rem 1rem' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>⚡</div>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 600 }}>No Automation Rules Defined</h3>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', maxWidth: 400, margin: '0.5rem auto 1.5rem auto' }}>
|
||||
Create rules to automatically scrape full-text content or apply tags whenever new articles arrive from specific feeds.
|
||||
</p>
|
||||
<button className="btn-primary" onClick={() => setShowRuleModal(true)}>
|
||||
<Plus size={14} />
|
||||
<span>Create Your First Rule</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="settings-card" style={{ padding: '1rem 1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
background: rule.is_active ? 'rgba(16, 185, 129, 0.1)' : 'var(--bg-tertiary)',
|
||||
color: rule.is_active ? 'var(--success)' : 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Zap size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{rule.name}</span>
|
||||
<span className="tag-badge" style={{ fontSize: '0.7rem' }}>
|
||||
Trigger: {rule.trigger}
|
||||
</span>
|
||||
{!rule.is_active && (
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>(Inactive)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{rule.conditions && rule.conditions.length > 0 ? (
|
||||
rule.conditions.map((c, i) => (
|
||||
<span key={i} style={{ background: 'var(--bg-tertiary)', padding: '2px 6px', borderRadius: 4 }}>
|
||||
If <strong>{c.field}</strong> {c.operator} <em>"{String(c.value)}"</em>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span>Always matches</span>
|
||||
)}
|
||||
<span>➜</span>
|
||||
{rule.actions?.map((a, i) => (
|
||||
<span key={i} style={{ background: 'rgba(56, 189, 248, 0.1)', color: 'var(--accent)', padding: '2px 6px', borderRadius: 4, fontWeight: 500 }}>
|
||||
Action: {a.type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className={`btn-secondary`}
|
||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
||||
onClick={() => handleToggleRuleActive(rule)}
|
||||
>
|
||||
{rule.is_active ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
title="Delete Rule"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Viewer Preferences Tab */}
|
||||
{activeTab === 'viewer' && (
|
||||
<div>
|
||||
@@ -975,6 +1181,144 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Automation Rule Modal */}
|
||||
{showRuleModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowRuleModal(false)}>
|
||||
<div className="modal-card" style={{ maxWidth: 520 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Sparkles size={17} style={{ color: 'var(--accent)' }} />
|
||||
<h3>Create Automation / Hook Rule</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowRuleModal(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateRule}>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Rule Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="e.g. Auto Scrape TechCrunch"
|
||||
value={ruleName}
|
||||
onChange={(e) => setRuleName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Trigger Event</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={ruleTrigger}
|
||||
onChange={(e) => setRuleTrigger(e.target.value)}
|
||||
>
|
||||
<option value="on_post_created">On New Post Ingested</option>
|
||||
<option value="on_variant_updated">On Variant Updated</option>
|
||||
<option value="manual">Manual Execution</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Condition Matching</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={ruleMatchMode}
|
||||
onChange={(e) => setRuleMatchMode(e.target.value)}
|
||||
>
|
||||
<option value="all">All Conditions Match (AND)</option>
|
||||
<option value="any">Any Condition Matches (OR)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-tertiary)', padding: '0.85rem', borderRadius: 8, margin: '0.5rem 0 1rem 0' }}>
|
||||
<label className="form-label" style={{ marginBottom: '0.5rem' }}>Condition Predicate</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={ruleField}
|
||||
onChange={(e) => setRuleField(e.target.value)}
|
||||
>
|
||||
<option value="feed_id">Feed ID</option>
|
||||
<option value="feed_title">Feed Title</option>
|
||||
<option value="title">Article Title</option>
|
||||
<option value="author">Author</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="variants.keywords">Keywords (Variant)</option>
|
||||
<option value="variants.tags">Tags (Variant)</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="form-select"
|
||||
value={ruleOp}
|
||||
onChange={(e) => setRuleOp(e.target.value)}
|
||||
>
|
||||
<option value="eq">Equals (==)</option>
|
||||
<option value="neq">Not Equals (!=)</option>
|
||||
<option value="contains">Contains substring</option>
|
||||
<option value="not_contains">Does not contain</option>
|
||||
<option value="matches">Regex Match</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{ruleField === 'feed_id' ? (
|
||||
<select
|
||||
className="form-select"
|
||||
value={ruleVal}
|
||||
onChange={(e) => setRuleVal(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Subscribed Feed...</option>
|
||||
{feeds.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Expected value (e.g. AI, OpenAI)"
|
||||
value={ruleVal}
|
||||
onChange={(e) => setRuleVal(e.target.value)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Action To Trigger</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={ruleActionType}
|
||||
onChange={(e) => setRuleActionType(e.target.value)}
|
||||
>
|
||||
<option value="readability_scrape">⚡ Scrape Full-Text (Readability)</option>
|
||||
<option value="mark_starred">⭐ Mark as Starred</option>
|
||||
<option value="mark_read">✓ Mark as Read</option>
|
||||
</select>
|
||||
</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={() => setShowRuleModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
Create Rule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Star,
|
||||
CheckCircle2,
|
||||
@@ -10,6 +11,11 @@ import {
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
CheckCheck,
|
||||
FileText,
|
||||
Zap,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ThreePaneViewProps {
|
||||
@@ -25,6 +31,7 @@ interface ThreePaneViewProps {
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
@@ -41,10 +48,13 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onMarkAllRead,
|
||||
onUpdateEntry,
|
||||
sidebarCollapsed,
|
||||
}) => {
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
||||
|
||||
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
||||
|
||||
@@ -58,6 +68,13 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
}
|
||||
}, [selectedIndex, entries.length]);
|
||||
|
||||
// Reset variant selection when article changes
|
||||
useEffect(() => {
|
||||
if (selectedEntry?.variants?.['readability_full_text']) {
|
||||
// If full text is available, you can choose to stay on current or default to original
|
||||
}
|
||||
}, [selectedEntry?.id]);
|
||||
|
||||
// When switching selected article, reset reader scroll position to top and handle 2s timer for short content
|
||||
useEffect(() => {
|
||||
if (readerPaneRef.current) {
|
||||
@@ -108,6 +125,22 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrapeFullText = async () => {
|
||||
if (!selectedEntry || isScraping) return;
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const updated = await api.scrapeEntry(selectedEntry.id);
|
||||
if (onUpdateEntry) {
|
||||
onUpdateEntry(updated);
|
||||
}
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} catch (err: any) {
|
||||
alert(`Scraping failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getListTitle = () => {
|
||||
if (filter.search) return `Search: "${filter.search}"`;
|
||||
if (filter.feedId) {
|
||||
@@ -123,6 +156,21 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
return 'Inbox';
|
||||
};
|
||||
|
||||
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
|
||||
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
|
||||
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
|
||||
|
||||
const getRenderedContent = () => {
|
||||
if (!selectedEntry) return '';
|
||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||
}
|
||||
if (selectedVariantKey === 'ai_summary' && selectedEntry.variants?.['ai_summary']) {
|
||||
return `<div style="padding: 1rem; border-left: 3px solid var(--accent); background: rgba(56, 189, 248, 0.05); margin-bottom: 1.5rem; border-radius: 4px;"><strong>AI Summary:</strong><p>${selectedEntry.variants['ai_summary'].text_value}</p></div>` + (selectedEntry.content || selectedEntry.raw_content || '');
|
||||
}
|
||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="three-pane-container">
|
||||
{/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */}
|
||||
@@ -235,6 +283,96 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
</button>
|
||||
|
||||
{/* Sliding Ball Capsule Toggle Button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`capsule-switch-btn ${
|
||||
selectedVariantKey === 'readability_full_text' ? 'active' : ''
|
||||
} ${isScraping ? 'scraping' : ''}`}
|
||||
onClick={() => {
|
||||
if (isScraping) return;
|
||||
if (selectedVariantKey === 'readability_full_text') {
|
||||
setSelectedVariantKey('original');
|
||||
} else {
|
||||
if (hasReadability) {
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} else {
|
||||
handleScrapeFullText();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
selectedVariantKey === 'readability_full_text'
|
||||
? 'Switch to Original RSS content'
|
||||
: hasReadability
|
||||
? 'Switch to Clean Full Text'
|
||||
: 'Scrape and view Clean Full Text'
|
||||
}
|
||||
>
|
||||
<div className="capsule-switch-track">
|
||||
<span className="capsule-switch-label">
|
||||
{isScraping
|
||||
? 'Scraping...'
|
||||
: selectedVariantKey === 'readability_full_text'
|
||||
? 'Full Text'
|
||||
: 'Original'}
|
||||
</span>
|
||||
<div className="capsule-switch-thumb">
|
||||
{isScraping ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : selectedVariantKey === 'readability_full_text' ? (
|
||||
<Zap size={12} />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasAISummary && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'ai_summary' ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 9px',
|
||||
fontSize: '0.72rem',
|
||||
background: selectedVariantKey === 'ai_summary' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'ai_summary' ? '#a855f7' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'ai_summary' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('ai_summary')}
|
||||
title="AI Summary"
|
||||
>
|
||||
<Sparkles size={12} style={{ marginRight: 4 }} />
|
||||
Summary
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasTTS && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 9px',
|
||||
fontSize: '0.72rem',
|
||||
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('tts')}
|
||||
title="Audio Stream"
|
||||
>
|
||||
<Volume2 size={12} style={{ marginRight: 4 }} />
|
||||
Audio
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
@@ -295,10 +433,22 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{/* Audio player if TTS variant is selected */}
|
||||
{selectedVariantKey === 'tts' && (
|
||||
<div style={{ margin: '1rem 0', padding: '1rem', background: 'var(--bg-tertiary)', borderRadius: '8px' }}>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>🎧 Audio Stream:</p>
|
||||
<audio
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
__html: getRenderedContent(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -314,3 +464,4 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { Category, FeedEntry, FilterState } from '../types';
|
||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
||||
import {
|
||||
@@ -13,6 +14,11 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
FileText,
|
||||
Zap,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WaterfallViewProps {
|
||||
@@ -22,6 +28,7 @@ interface WaterfallViewProps {
|
||||
entries: FeedEntry[];
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||
}
|
||||
|
||||
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
@@ -31,10 +38,51 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
entries,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onUpdateEntry,
|
||||
}) => {
|
||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||
const [isScraping, setIsScraping] = useState(false);
|
||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset variant selection when selected article changes
|
||||
useEffect(() => {
|
||||
setSelectedVariantKey('original');
|
||||
}, [selectedEntry?.id]);
|
||||
|
||||
const handleScrapeFullText = async () => {
|
||||
if (!selectedEntry || isScraping) return;
|
||||
setIsScraping(true);
|
||||
try {
|
||||
const updated = await api.scrapeEntry(selectedEntry.id);
|
||||
setSelectedEntry(updated);
|
||||
onUpdateEntry?.(updated);
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} catch (err: any) {
|
||||
alert(`Scraping failed: ${err.message || err}`);
|
||||
} finally {
|
||||
setIsScraping(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
|
||||
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
|
||||
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
|
||||
|
||||
const getRenderedContent = () => {
|
||||
if (!selectedEntry) return '';
|
||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
||||
}
|
||||
if (selectedVariantKey === 'ai_summary' && selectedEntry.variants?.['ai_summary']) {
|
||||
return (
|
||||
`<div style="padding: 1rem; border-left: 3px solid var(--accent); background: rgba(56, 189, 248, 0.05); margin-bottom: 1.5rem; border-radius: 4px;"><strong>AI Summary:</strong><p>${selectedEntry.variants['ai_summary'].text_value}</p></div>` +
|
||||
(selectedEntry.content || selectedEntry.raw_content || '')
|
||||
);
|
||||
}
|
||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
||||
};
|
||||
|
||||
// When card is clicked, open in full-screen reader view and immediately mark as read
|
||||
const handleCardClick = (entry: FeedEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
@@ -232,7 +280,95 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Sliding Ball Capsule Toggle Button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`capsule-switch-btn ${
|
||||
selectedVariantKey === 'readability_full_text' ? 'active' : ''
|
||||
} ${isScraping ? 'scraping' : ''}`}
|
||||
onClick={() => {
|
||||
if (isScraping) return;
|
||||
if (selectedVariantKey === 'readability_full_text') {
|
||||
setSelectedVariantKey('original');
|
||||
} else {
|
||||
if (hasReadability) {
|
||||
setSelectedVariantKey('readability_full_text');
|
||||
} else {
|
||||
handleScrapeFullText();
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={
|
||||
selectedVariantKey === 'readability_full_text'
|
||||
? 'Switch to Original RSS content'
|
||||
: hasReadability
|
||||
? 'Switch to Clean Full Text'
|
||||
: 'Scrape and view Clean Full Text'
|
||||
}
|
||||
>
|
||||
<div className="capsule-switch-track">
|
||||
<span className="capsule-switch-label">
|
||||
{isScraping
|
||||
? 'Scraping...'
|
||||
: selectedVariantKey === 'readability_full_text'
|
||||
? 'Full Text'
|
||||
: 'Original'}
|
||||
</span>
|
||||
<div className="capsule-switch-thumb">
|
||||
{isScraping ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : selectedVariantKey === 'readability_full_text' ? (
|
||||
<Zap size={12} />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasAISummary && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'ai_summary' ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.7rem',
|
||||
background: selectedVariantKey === 'ai_summary' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'ai_summary' ? '#a855f7' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'ai_summary' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('ai_summary')}
|
||||
title="AI Summary"
|
||||
>
|
||||
<Sparkles size={12} style={{ marginRight: 3 }} />
|
||||
Summary
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasTTS && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
padding: '3px 8px',
|
||||
fontSize: '0.7rem',
|
||||
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
|
||||
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
|
||||
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
onClick={() => setSelectedVariantKey('tts')}
|
||||
title="Audio Stream"
|
||||
>
|
||||
<Volume2 size={12} style={{ marginRight: 3 }} />
|
||||
Audio
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
@@ -312,12 +448,23 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
{/* Dynamic Content Variant Rendering */}
|
||||
{selectedVariantKey === 'tts' && hasTTS ? (
|
||||
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
|
||||
<audio
|
||||
controls
|
||||
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getRenderedContent(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user