feat: implement rule application engine with full-text variant support, batch entry processing, and tag visualization in UI.

This commit is contained in:
2026-08-25 22:57:48 -07:00
parent 235966e933
commit 83c70cc3b9
10 changed files with 667 additions and 107 deletions
+445 -94
View File
@@ -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>
+17 -1
View File
@@ -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}
+17 -1
View File
@@ -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}