feat: implement rule application engine with full-text variant support, batch entry processing, and tag visualization in UI.
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
Shield,
|
||||
Clock,
|
||||
Timer,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SettingsViewProps {
|
||||
@@ -76,6 +77,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
const [rulesLoading, setRulesLoading] = useState(false);
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [saveDropdownOpen, setSaveDropdownOpen] = useState(false);
|
||||
|
||||
// Form State for Automation Rule Builder
|
||||
const [ruleName, setRuleName] = useState('');
|
||||
@@ -120,7 +123,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
|
||||
// Custom Delete Confirmation Modal State
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
type: 'feed' | 'category';
|
||||
type: 'feed' | 'category' | 'rule';
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
@@ -210,6 +213,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
variantValue: '',
|
||||
contentType: 'text',
|
||||
});
|
||||
setSaveDropdownOpen(false);
|
||||
setShowRuleModal(true);
|
||||
};
|
||||
|
||||
@@ -286,14 +290,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
contentType: cType,
|
||||
});
|
||||
|
||||
setSaveDropdownOpen(false);
|
||||
setShowRuleModal(true);
|
||||
};
|
||||
|
||||
const handleSaveRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSaveRule = async (e?: React.FormEvent, applyToExisting: boolean = false) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!ruleName.trim()) {
|
||||
alert('Please provide a descriptive rule name.');
|
||||
return;
|
||||
return; // Inline validation below the input field already shows the error
|
||||
}
|
||||
|
||||
// Compile Conditions
|
||||
@@ -338,7 +342,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveLoading(true);
|
||||
setSaveDropdownOpen(false);
|
||||
try {
|
||||
let targetRuleId = editingRuleId;
|
||||
if (editingRuleId) {
|
||||
await api.updateRule(editingRuleId, {
|
||||
name: ruleName.trim(),
|
||||
@@ -348,22 +355,31 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
actions: compiledActions,
|
||||
is_active: true,
|
||||
});
|
||||
setMessage(`Rule "${ruleName}" updated successfully.`);
|
||||
} else {
|
||||
await api.createRule({
|
||||
const created = await api.createRule({
|
||||
name: ruleName.trim(),
|
||||
trigger: ruleTrigger,
|
||||
match_mode: ruleMatchMode,
|
||||
conditions: compiledConditions,
|
||||
actions: compiledActions,
|
||||
});
|
||||
setMessage(`Rule "${ruleName}" created successfully.`);
|
||||
targetRuleId = created.id;
|
||||
}
|
||||
|
||||
if (applyToExisting && targetRuleId) {
|
||||
const res = await api.applyRule(targetRuleId);
|
||||
setMessage(`Rule "${ruleName}" saved and applied to ${res.matched_count} matching post(s).`);
|
||||
} else {
|
||||
setMessage(`Rule "${ruleName}" ${editingRuleId ? 'updated' : 'created'} successfully.`);
|
||||
}
|
||||
|
||||
setShowRuleModal(false);
|
||||
loadRules();
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
setTimeout(() => setMessage(null), 3500);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to save rule');
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -383,16 +399,6 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
const getNaturalLanguageSummary = (rule: Rule) => {
|
||||
const triggerText = rule.trigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Ingested';
|
||||
const condTexts: string[] = [];
|
||||
@@ -509,9 +515,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
if (deleteConfirm.type === 'feed') {
|
||||
await api.deleteFeed(deleteConfirm.id);
|
||||
setMessage(`Unsubscribed from "${deleteConfirm.title}".`);
|
||||
} else {
|
||||
} else if (deleteConfirm.type === 'category') {
|
||||
await api.deleteCategory(deleteConfirm.id);
|
||||
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
||||
} else if (deleteConfirm.type === 'rule') {
|
||||
await api.deleteRule(deleteConfirm.id);
|
||||
setMessage(`Deleted rule "${deleteConfirm.title}".`);
|
||||
loadRules();
|
||||
}
|
||||
onRefreshData();
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
@@ -909,8 +919,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: '8px',
|
||||
background: rule.is_active ? 'rgba(2, 132, 199, 0.1)' : 'var(--bg-tertiary)',
|
||||
color: rule.is_active ? '#0284c7' : 'var(--text-muted)',
|
||||
background: rule.is_active ? 'var(--bg-tertiary)' : 'var(--bg-app)',
|
||||
border: '1px solid var(--border)',
|
||||
color: rule.is_active ? 'var(--text-main)' : 'var(--text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -918,7 +929,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<Zap size={17} />
|
||||
<Zap size={16} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -936,11 +947,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
|
||||
{/* Natural Language Sentence Preview */}
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-secondary)', marginTop: '0.45rem', lineHeight: 1.45 }}>
|
||||
When <strong>{summary.trigger}</strong>{' '}
|
||||
When <strong style={{ color: 'var(--text-main)' }}>{summary.trigger}</strong>{' '}
|
||||
<span style={{ color: 'var(--text-muted)' }}>matching</span>{' '}
|
||||
<span style={{ color: '#0284c7', fontWeight: 600 }}>{summary.conditions}</span>{' '}
|
||||
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>{summary.conditions}</span>{' '}
|
||||
<span style={{ color: 'var(--text-muted)' }}>➜ Then</span>{' '}
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>{summary.actions}</span>
|
||||
<span style={{ color: '#10b981', fontWeight: 600 }}>{summary.actions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -964,7 +975,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28, color: 'var(--danger)' }}
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirm({
|
||||
type: 'rule',
|
||||
id: rule.id,
|
||||
title: rule.name,
|
||||
});
|
||||
}}
|
||||
title="Delete Rule"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
@@ -1416,7 +1434,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</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'}{' '}
|
||||
Are you sure you want to {deleteConfirm.type === 'feed' ? 'unsubscribe from' : deleteConfirm.type === 'category' ? 'delete category' : 'delete rule'}{' '}
|
||||
<strong>"{deleteConfirm.title}"</strong>?
|
||||
{deleteConfirm.type === 'category' && (
|
||||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)', fontSize: '0.78rem' }}>
|
||||
@@ -1444,37 +1462,86 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
{/* Create / Edit Automation Rule Modal */}
|
||||
{showRuleModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowRuleModal(false)}>
|
||||
<div className="modal-card" style={{ maxWidth: 640, maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header" style={{ flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<Sparkles size={18} style={{ color: 'var(--accent)' }} />
|
||||
<h3>{editingRuleId ? 'Edit Automation Rule' : 'Create Automation Rule'}</h3>
|
||||
<div
|
||||
className="modal-card"
|
||||
style={{
|
||||
maxWidth: 660,
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-dark)',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.6)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (saveDropdownOpen) setSaveDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="modal-header" style={{ flexShrink: 0, borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Sparkles size={18} style={{ color: 'var(--text-main)' }} />
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
{editingRuleId ? 'Edit Automation Rule' : 'Create Automation Rule'}
|
||||
</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setShowRuleModal(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveRule} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||
<div className="modal-body" style={{ overflowY: 'auto', flex: 1, paddingRight: '1rem' }}>
|
||||
<form
|
||||
onSubmit={(e) => handleSaveRule(e, false)}
|
||||
style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}
|
||||
>
|
||||
<div className="modal-body" style={{ overflowY: 'auto', flex: 1, padding: '1.25rem' }}>
|
||||
{/* Rule Name */}
|
||||
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
|
||||
<label className="form-label" style={{ fontWeight: 600 }}>Rule Name</label>
|
||||
<label className="form-label" style={{ fontWeight: 600, color: 'var(--text-main)' }}>
|
||||
Rule Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="e.g. Auto Scrape Tech Subscriptions"
|
||||
value={ruleName}
|
||||
onChange={(e) => setRuleName(e.target.value)}
|
||||
required
|
||||
style={!ruleName.trim() ? { borderColor: 'var(--danger)' } : undefined}
|
||||
/>
|
||||
{!ruleName.trim() && (
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--danger)', marginTop: '0.25rem' }}>
|
||||
Please provide a descriptive rule name.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LEVEL 1: TRIGGER SELECTION */}
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8, padding: '1rem', marginBottom: '1.25rem' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-app)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '1rem',
|
||||
marginBottom: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: '0.75rem' }}>
|
||||
<span style={{ background: '#0284c7', color: '#fff', fontSize: '0.7rem', fontWeight: 700, padding: '1px 6px', borderRadius: 4 }}>1</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>When This Trigger Occurs</span>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-dark)',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
1
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>
|
||||
When This Trigger Occurs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
@@ -1485,8 +1552,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
gap: '0.5rem',
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 6,
|
||||
border: `1.5px solid ${ruleTrigger === 'on_post_created' ? '#0284c7' : 'var(--border)'}`,
|
||||
background: ruleTrigger === 'on_post_created' ? 'rgba(2, 132, 199, 0.05)' : 'var(--bg-app)',
|
||||
border: `1.5px solid ${ruleTrigger === 'on_post_created' ? 'var(--border-dark)' : 'var(--border)'}`,
|
||||
background: ruleTrigger === 'on_post_created' ? 'var(--bg-tertiary)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@@ -1495,9 +1562,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
name="rule_trigger"
|
||||
checked={ruleTrigger === 'on_post_created'}
|
||||
onChange={() => setRuleTrigger('on_post_created')}
|
||||
style={{ accentColor: '#a1a1aa' }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>New Post Ingested</div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-main)' }}>
|
||||
New Post Ingested
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Trigger on new RSS arrival</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -1509,8 +1579,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
gap: '0.5rem',
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 6,
|
||||
border: `1.5px solid ${ruleTrigger === 'on_variant_updated' ? '#0284c7' : 'var(--border)'}`,
|
||||
background: ruleTrigger === 'on_variant_updated' ? 'rgba(2, 132, 199, 0.05)' : 'var(--bg-app)',
|
||||
border: `1.5px solid ${ruleTrigger === 'on_variant_updated' ? 'var(--border-dark)' : 'var(--border)'}`,
|
||||
background: ruleTrigger === 'on_variant_updated' ? 'var(--bg-tertiary)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
@@ -1519,18 +1589,34 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
name="rule_trigger"
|
||||
checked={ruleTrigger === 'on_variant_updated'}
|
||||
onChange={() => setRuleTrigger('on_variant_updated')}
|
||||
style={{ accentColor: '#a1a1aa' }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>Variant Changed</div>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-main)' }}>
|
||||
Variant Changed
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Trigger on enrichment update</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{ruleTrigger === 'on_variant_updated' && (
|
||||
<div style={{ marginTop: '0.85rem', padding: '0.75rem', background: 'var(--bg-app)', borderRadius: 6, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.85rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.75rem' }}>Sub-Event Type</label>
|
||||
<label className="form-label" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||
Sub-Event Type
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={variantSubEvent}
|
||||
@@ -1543,7 +1629,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.75rem' }}>Target Variant Key</label>
|
||||
<label className="form-label" style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||
Target Variant Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
@@ -1557,11 +1645,33 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* LEVEL 2: CONDITIONS & FILTER SCOPE */}
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8, padding: '1rem', marginBottom: '1.25rem' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-app)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '1rem',
|
||||
marginBottom: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ background: '#0284c7', color: '#fff', fontSize: '0.7rem', fontWeight: 700, padding: '1px 6px', borderRadius: 4 }}>2</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>Match Conditions (Filter Scope)</span>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-dark)',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
2
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>
|
||||
Match Conditions (Filter Scope)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.75rem' }}>
|
||||
@@ -1580,8 +1690,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{builderConditions.map((cond, idx) => (
|
||||
<div key={cond.id} style={{ background: 'var(--bg-app)', border: '1px solid var(--border)', borderRadius: 6, padding: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr auto', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||
<div
|
||||
key={cond.id}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
padding: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1.2fr 1fr auto',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Field Selector */}
|
||||
<select
|
||||
className="form-select"
|
||||
@@ -1691,16 +1817,49 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
|
||||
{/* Contextual Value Input */}
|
||||
{cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in') ? (
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 6, padding: '0.6rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, background: 'var(--bg-app)', border: '1px solid var(--border)', borderRadius: 4, padding: '0.15rem 0.4rem', flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-app)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
padding: '0.6rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.4rem',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
padding: '0.15rem 0.4rem',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Search size={12} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter feeds..."
|
||||
value={feedPickerSearch}
|
||||
onChange={(e) => setFeedPickerSearch(e.target.value)}
|
||||
style={{ border: 'none', background: 'transparent', outline: 'none', fontSize: '0.75rem', width: '100%', color: 'var(--text-main)' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
outline: 'none',
|
||||
fontSize: '0.75rem',
|
||||
width: '100%',
|
||||
color: 'var(--text-main)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1746,11 +1905,19 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.72rem', color: '#0284c7', fontWeight: 600, marginBottom: '0.35rem' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary)', fontWeight: 600, marginBottom: '0.35rem' }}>
|
||||
{cond.selectedFeedIds.length} of {feeds.length} feeds selected
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 130, overflowY: 'auto', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.35rem' }}>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 130,
|
||||
overflowY: 'auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '0.35rem',
|
||||
}}
|
||||
>
|
||||
{feeds
|
||||
.filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase()))
|
||||
.map((feed) => {
|
||||
@@ -1764,7 +1931,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
gap: '0.4rem',
|
||||
padding: '0.3rem 0.45rem',
|
||||
borderRadius: 4,
|
||||
background: isChecked ? 'rgba(2, 132, 199, 0.08)' : 'transparent',
|
||||
background: isChecked ? 'var(--bg-tertiary)' : 'transparent',
|
||||
border: `1px solid ${isChecked ? 'var(--border-dark)' : 'transparent'}`,
|
||||
fontSize: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
@@ -1781,9 +1949,17 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
updated[idx] = { ...cond, selectedFeedIds: next };
|
||||
setBuilderConditions(updated);
|
||||
}}
|
||||
style={{ accentColor: '#0284c7' }}
|
||||
style={{ accentColor: '#a1a1aa' }}
|
||||
/>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isChecked ? 600 : 400 }}>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: isChecked ? 600 : 400,
|
||||
color: isChecked ? 'var(--text-main)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{feed.title}
|
||||
</span>
|
||||
</label>
|
||||
@@ -1825,9 +2001,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
setBuilderConditions(updated);
|
||||
}}
|
||||
style={{
|
||||
border: `1px solid ${isChecked ? '#0284c7' : 'var(--border)'}`,
|
||||
background: isChecked ? 'rgba(2, 132, 199, 0.1)' : 'var(--bg-card)',
|
||||
color: isChecked ? '#0284c7' : 'var(--text-main)',
|
||||
border: `1px solid ${isChecked ? 'var(--border-dark)' : 'var(--border)'}`,
|
||||
background: isChecked ? 'var(--bg-tertiary)' : 'var(--bg-app)',
|
||||
color: isChecked ? 'var(--text-main)' : 'var(--text-secondary)',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: 4,
|
||||
fontSize: '0.75rem',
|
||||
@@ -1879,7 +2055,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder={cond.operator === 'contains_any' ? 'e.g. AI, OpenAI, DeepSeek (comma-separated)' : 'Expected value'}
|
||||
placeholder={
|
||||
cond.operator === 'contains_any'
|
||||
? 'e.g. AI, OpenAI, DeepSeek (comma-separated)'
|
||||
: 'Expected value'
|
||||
}
|
||||
value={cond.value}
|
||||
onChange={(e) => {
|
||||
const updated = [...builderConditions];
|
||||
@@ -1916,10 +2096,32 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* LEVEL 3: ACTION EXECUTION PIPELINE */}
|
||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 8, padding: '1rem', marginBottom: '1.25rem' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-app)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '1rem',
|
||||
marginBottom: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: '0.75rem' }}>
|
||||
<span style={{ background: '#0284c7', color: '#fff', fontSize: '0.7rem', fontWeight: 700, padding: '1px 6px', borderRadius: 4 }}>3</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>Execute Action Pipeline</span>
|
||||
<span
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-dark)',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 700,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
3
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>
|
||||
Execute Action Pipeline
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||
@@ -1928,10 +2130,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
type="checkbox"
|
||||
checked={builderActions.scrapeFullText}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, scrapeFullText: e.target.checked })}
|
||||
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
||||
style={{ accentColor: '#a1a1aa', width: 16, height: 16 }}
|
||||
/>
|
||||
<div>
|
||||
<strong>⚡ Scrape Full Text (Readability)</strong>
|
||||
<strong style={{ color: 'var(--text-main)' }}>⚡ Scrape Full Text (Readability)</strong>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
Extracts clean full-text markdown and saves to <code>readability_full_text</code> variant.
|
||||
</div>
|
||||
@@ -1943,11 +2145,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
type="checkbox"
|
||||
checked={builderActions.markStarred}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, markStarred: e.target.checked })}
|
||||
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
||||
style={{ accentColor: '#a1a1aa', width: 16, height: 16 }}
|
||||
/>
|
||||
<div>
|
||||
<strong>⭐ Mark as Starred</strong>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Automatically star matching posts for priority reading.</div>
|
||||
<strong style={{ color: 'var(--text-main)' }}>⭐ Mark as Starred</strong>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
Automatically star matching posts for priority reading.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -1956,10 +2160,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
type="checkbox"
|
||||
checked={builderActions.markRead}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, markRead: e.target.checked })}
|
||||
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
||||
style={{ accentColor: '#a1a1aa', width: 16, height: 16 }}
|
||||
/>
|
||||
<div>
|
||||
<strong>✓ Mark as Read (Auto-Archive)</strong>
|
||||
<strong style={{ color: 'var(--text-main)' }}>✓ Mark as Read (Auto-Archive)</strong>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Automatically mark as read on ingestion.</div>
|
||||
</div>
|
||||
</label>
|
||||
@@ -1969,16 +2173,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
type="checkbox"
|
||||
checked={builderActions.setVariant}
|
||||
onChange={(e) => setBuilderActions({ ...builderActions, setVariant: e.target.checked })}
|
||||
style={{ accentColor: '#0284c7', width: 16, height: 16, marginTop: 2 }}
|
||||
style={{ accentColor: '#a1a1aa', width: 16, height: 16, marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong>🏷️ Set Custom Variant / Tag</strong>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Attach extensible variant metadata to the entry.</div>
|
||||
<strong style={{ color: 'var(--text-main)' }}>🏷️ Set Custom Variant / Tag</strong>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
Attach extensible variant metadata to the entry.
|
||||
</div>
|
||||
|
||||
{builderActions.setVariant && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>Variant Key</label>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||
Variant Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
@@ -1988,7 +2196,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>Value</label>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
@@ -1998,7 +2208,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>Content Type</label>
|
||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||
Content Type
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={builderActions.contentType}
|
||||
@@ -2017,16 +2229,32 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* NATURAL LANGUAGE PREVIEW SUMMARY CALLOUT */}
|
||||
<div style={{ background: 'rgba(2, 132, 199, 0.05)', border: '1px solid rgba(2, 132, 199, 0.2)', borderRadius: 6, padding: '0.75rem 0.85rem' }}>
|
||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: '#0284c7', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-dark)',
|
||||
borderRadius: 6,
|
||||
padding: '0.75rem 0.85rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
marginBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
Rule Summary Preview
|
||||
</div>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-main)', lineHeight: 1.45 }}>
|
||||
When <strong>{ruleTrigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Arrives'}</strong>{' '}
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>
|
||||
When <strong style={{ color: 'var(--text-main)' }}>{ruleTrigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Arrives'}</strong>{' '}
|
||||
{builderConditions.length > 0 && (
|
||||
<>
|
||||
<span style={{ color: 'var(--text-muted)' }}>where</span>{' '}
|
||||
<span style={{ color: '#0284c7', fontWeight: 600 }}>
|
||||
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>
|
||||
{builderConditions
|
||||
.map((c) => {
|
||||
if (c.field === 'feed_id' && (c.operator === 'in' || c.operator === 'not_in')) {
|
||||
@@ -2039,7 +2267,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</>
|
||||
)}
|
||||
{' '}➜ Then{' '}
|
||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
|
||||
<span style={{ color: '#10b981', fontWeight: 600 }}>
|
||||
{[
|
||||
builderActions.scrapeFullText && 'Scrape Full Text',
|
||||
builderActions.markStarred && 'Star Post',
|
||||
@@ -2053,13 +2281,136 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<button type="button" className="btn-secondary" onClick={() => setShowRuleModal(false)}>
|
||||
{/* FOOTER WITH SPLIT BUTTON */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0.85rem 1.25rem',
|
||||
borderTop: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setShowRuleModal(false)}
|
||||
disabled={saveLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
{editingRuleId ? 'Save Rule Changes' : 'Create Automation Rule'}
|
||||
</button>
|
||||
|
||||
{/* Split Button Container */}
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'stretch' }}>
|
||||
{/* Primary Save Action */}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={saveLoading}
|
||||
style={{
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderRight: '1px solid rgba(255,255,255,0.15)',
|
||||
}}
|
||||
>
|
||||
{saveLoading ? (
|
||||
<>
|
||||
<Loader2 size={13} className="spin" />
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{editingRuleId ? 'Save Changes' : 'Create Rule'}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Arrow Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={saveLoading}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSaveDropdownOpen(!saveDropdownOpen);
|
||||
}}
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
padding: '0.38rem 0.45rem',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="More save options"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
{/* Popover Dropdown Menu */}
|
||||
{saveDropdownOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
right: 0,
|
||||
marginBottom: '0.4rem',
|
||||
background: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-dark)',
|
||||
borderRadius: 8,
|
||||
padding: '0.35rem',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
||||
minWidth: 290,
|
||||
zIndex: 100,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(() => {
|
||||
const canApplyExisting =
|
||||
ruleTrigger === 'on_post_created' ||
|
||||
(ruleTrigger === 'on_variant_updated' && variantSubEvent === 'key_added');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canApplyExisting || saveLoading}
|
||||
onClick={() => handleSaveRule(undefined, true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: 6,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: canApplyExisting ? 'var(--text-main)' : 'var(--text-muted)',
|
||||
cursor: canApplyExisting ? 'pointer' : 'not-allowed',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (canApplyExisting) e.currentTarget.style.background = 'var(--bg-tertiary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, fontSize: '0.85rem' }}>
|
||||
<Zap size={14} style={{ color: canApplyExisting ? '#f59e0b' : 'var(--text-muted)' }} />
|
||||
<span>Save & Apply to Existing Posts</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', lineHeight: 1.3 }}>
|
||||
{canApplyExisting
|
||||
? 'Saves rule and scans all subscribed feeds to immediately run the action pipeline on existing posts.'
|
||||
: 'Only available for "New Post" or "Key Added" triggers.'}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Star,
|
||||
@@ -239,6 +239,14 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
||||
|
||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
||||
|
||||
{getEntryTags(entry.variants).length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{getEntryTags(entry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -427,6 +435,14 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getEntryTags(selectedEntry.variants).length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: '0.75rem' }}>
|
||||
{getEntryTags(selectedEntry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
Inbox,
|
||||
@@ -213,6 +213,14 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
||||
|
||||
{getEntryTags(entry.variants).length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4, marginBottom: 4 }}>
|
||||
{getEntryTags(entry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="xhs-card-footer">
|
||||
<div className="xhs-publisher-badge">
|
||||
@@ -442,6 +450,14 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{getEntryTags(selectedEntry.variants).length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: '0.75rem' }}>
|
||||
{getEntryTags(selectedEntry.variants).map((tag) => (
|
||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
|
||||
+19
-2
@@ -266,6 +266,21 @@ html, body, #root {
|
||||
border: 1px solid var(--tag-border);
|
||||
}
|
||||
|
||||
.variant-tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Mobile Dropdown Menu */
|
||||
.mobile-menu-dropdown {
|
||||
position: absolute;
|
||||
@@ -682,11 +697,13 @@ html, body, #root {
|
||||
|
||||
.reader-source-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
gap: 0.4rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.reader-headline {
|
||||
|
||||
@@ -211,6 +211,12 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async applyRule(id: string): Promise<{ message: string; matched_count: number; queued_jobs: number }> {
|
||||
return request<{ message: string; matched_count: number; queued_jobs: number }>(`/rules/${id}/apply`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// OPML
|
||||
async importOPML(file: File): Promise<{ imported_count: number; total_parsed: number }> {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
|
||||
@@ -61,3 +61,17 @@ export function estimateReadingTime(content?: string): string {
|
||||
const mins = Math.ceil(words / 200);
|
||||
return `${mins} min read`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tags from an entry's variants map.
|
||||
* Tags are stored as variant key "tags" with comma-separated text_value.
|
||||
*/
|
||||
export function getEntryTags(variants?: Record<string, { text_value?: string }>): string[] {
|
||||
if (!variants) return [];
|
||||
const tagVariant = variants['tags'];
|
||||
if (!tagVariant?.text_value) return [];
|
||||
return tagVariant.text_value
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user