2071 lines
91 KiB
TypeScript
2071 lines
91 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User } from '../types';
|
|
import { api } from '../services/api';
|
|
import {
|
|
Rss,
|
|
Folder,
|
|
Trash2,
|
|
RefreshCw,
|
|
Plus,
|
|
Search,
|
|
CheckCircle2,
|
|
UploadCloud,
|
|
Download,
|
|
Loader2,
|
|
Edit2,
|
|
AlertCircle,
|
|
X,
|
|
Lock,
|
|
Key,
|
|
Sparkles,
|
|
Zap,
|
|
Eye,
|
|
FileSpreadsheet,
|
|
Shield,
|
|
Clock,
|
|
Timer,
|
|
} from 'lucide-react';
|
|
|
|
interface SettingsViewProps {
|
|
currentUser: User;
|
|
feeds: Feed[];
|
|
categories: Category[];
|
|
stats: Stats | null;
|
|
onRefreshData: () => void;
|
|
onOpenAddFeed: () => void;
|
|
}
|
|
|
|
interface BuilderCondition {
|
|
id: string;
|
|
field: string;
|
|
operator: string;
|
|
value: string;
|
|
selectedFeedIds: string[];
|
|
selectedCatIds: string[];
|
|
}
|
|
|
|
interface BuilderActionsState {
|
|
scrapeFullText: boolean;
|
|
markStarred: boolean;
|
|
markRead: boolean;
|
|
setVariant: boolean;
|
|
variantKey: string;
|
|
variantValue: string;
|
|
contentType: string;
|
|
}
|
|
|
|
export const SettingsView: React.FC<SettingsViewProps> = ({
|
|
currentUser,
|
|
feeds,
|
|
categories,
|
|
stats,
|
|
onRefreshData,
|
|
onOpenAddFeed,
|
|
}) => {
|
|
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'viewer' | 'opml' | 'account'>('feeds');
|
|
const [autoReadBottom, setAutoReadBottom] = useState<boolean>(() => {
|
|
return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
|
});
|
|
const [feedSearch, setFeedSearch] = useState('');
|
|
const [newCatTitle, setNewCatTitle] = useState('');
|
|
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 [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
|
|
|
// Form State for Automation Rule Builder
|
|
const [ruleName, setRuleName] = useState('');
|
|
const [ruleTrigger, setRuleTrigger] = useState<'on_post_created' | 'on_variant_updated'>('on_post_created');
|
|
const [variantSubEvent, setVariantSubEvent] = useState<'key_added' | 'value_changed' | 'key_deleted'>('key_added');
|
|
const [targetVariantKey, setTargetVariantKey] = useState('keywords');
|
|
const [ruleMatchMode, setRuleMatchMode] = useState<'all' | 'any'>('all');
|
|
|
|
const [builderConditions, setBuilderConditions] = useState<BuilderCondition[]>([
|
|
{
|
|
id: 'cond-1',
|
|
field: 'feed_id',
|
|
operator: 'in',
|
|
value: '',
|
|
selectedFeedIds: [],
|
|
selectedCatIds: [],
|
|
},
|
|
]);
|
|
|
|
const [feedPickerSearch, setFeedPickerSearch] = useState('');
|
|
|
|
const [builderActions, setBuilderActions] = useState<BuilderActionsState>({
|
|
scrapeFullText: true,
|
|
markStarred: false,
|
|
markRead: false,
|
|
setVariant: false,
|
|
variantKey: 'tags',
|
|
variantValue: '',
|
|
contentType: 'text',
|
|
});
|
|
|
|
// Edit Feed Modal State
|
|
const [editingFeed, setEditingFeed] = useState<Feed | null>(null);
|
|
const [editTitle, setEditTitle] = useState('');
|
|
const [editCategoryId, setEditCategoryId] = useState<string>('');
|
|
const [editInterval, setEditInterval] = useState<number>(30);
|
|
const [editTimeout, setEditTimeout] = useState<number>(10);
|
|
const [editSaving, setEditSaving] = useState(false);
|
|
|
|
// Debug Log Modal State
|
|
const [debugFeed, setDebugFeed] = useState<Feed | null>(null);
|
|
|
|
// Custom Delete Confirmation Modal State
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
|
type: 'feed' | 'category';
|
|
id: string;
|
|
title: string;
|
|
} | null>(null);
|
|
|
|
// OPML state
|
|
const [opmlFile, setOpmlFile] = useState<File | null>(null);
|
|
const [opmlLoading, setOpmlLoading] = useState(false);
|
|
const [opmlResult, setOpmlResult] = useState<string | null>(null);
|
|
|
|
// Change Password state
|
|
const [oldPassword, setOldPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
|
|
|
|
const handleChangePassword = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setPasswordError(null);
|
|
setPasswordSuccess(null);
|
|
|
|
if (newPassword.length < 6) {
|
|
setPasswordError('New password must be at least 6 characters long');
|
|
return;
|
|
}
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
setPasswordError('New passwords do not match');
|
|
return;
|
|
}
|
|
|
|
setPasswordLoading(true);
|
|
try {
|
|
await api.changePassword(oldPassword, newPassword);
|
|
setPasswordSuccess('Password updated successfully! Please use your new password next time you log in.');
|
|
setOldPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
} catch (err: any) {
|
|
setPasswordError(err.message || 'Failed to update password');
|
|
} finally {
|
|
setPasswordLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadRules();
|
|
}, []);
|
|
|
|
const loadRules = async () => {
|
|
setRulesLoading(true);
|
|
try {
|
|
const data = await api.listRules();
|
|
setRules(data || []);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setRulesLoading(false);
|
|
}
|
|
};
|
|
|
|
const openCreateRuleModal = () => {
|
|
setEditingRuleId(null);
|
|
setRuleName('');
|
|
setRuleTrigger('on_post_created');
|
|
setVariantSubEvent('key_added');
|
|
setTargetVariantKey('keywords');
|
|
setRuleMatchMode('all');
|
|
setBuilderConditions([
|
|
{
|
|
id: 'cond-1',
|
|
field: 'feed_id',
|
|
operator: 'in',
|
|
value: '',
|
|
selectedFeedIds: [],
|
|
selectedCatIds: [],
|
|
},
|
|
]);
|
|
setFeedPickerSearch('');
|
|
setBuilderActions({
|
|
scrapeFullText: true,
|
|
markStarred: false,
|
|
markRead: false,
|
|
setVariant: false,
|
|
variantKey: 'tags',
|
|
variantValue: '',
|
|
contentType: 'text',
|
|
});
|
|
setShowRuleModal(true);
|
|
};
|
|
|
|
const openEditRuleModal = (rule: Rule) => {
|
|
setEditingRuleId(rule.id);
|
|
setRuleName(rule.name);
|
|
setRuleTrigger(rule.trigger === 'on_variant_updated' ? 'on_variant_updated' : 'on_post_created');
|
|
setRuleMatchMode(rule.match_mode === 'any' ? 'any' : 'all');
|
|
|
|
const mappedConds: BuilderCondition[] = (rule.conditions || []).map((c, idx) => {
|
|
let feedIds: string[] = [];
|
|
let catIds: string[] = [];
|
|
let strVal = '';
|
|
|
|
if (c.field === 'feed_id') {
|
|
if (Array.isArray(c.value)) feedIds = c.value;
|
|
else if (typeof c.value === 'string') feedIds = c.value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
} else if (c.field === 'category_id') {
|
|
if (Array.isArray(c.value)) catIds = c.value;
|
|
else if (typeof c.value === 'string') catIds = c.value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
} else {
|
|
strVal = typeof c.value === 'object' ? JSON.stringify(c.value) : String(c.value ?? '');
|
|
}
|
|
|
|
return {
|
|
id: `cond-${idx}-${Date.now()}`,
|
|
field: c.field,
|
|
operator: c.operator,
|
|
value: strVal,
|
|
selectedFeedIds: feedIds,
|
|
selectedCatIds: catIds,
|
|
};
|
|
});
|
|
|
|
if (mappedConds.length === 0) {
|
|
mappedConds.push({
|
|
id: 'cond-1',
|
|
field: 'feed_id',
|
|
operator: 'in',
|
|
value: '',
|
|
selectedFeedIds: [],
|
|
selectedCatIds: [],
|
|
});
|
|
}
|
|
setBuilderConditions(mappedConds);
|
|
|
|
let scrape = false;
|
|
let starred = false;
|
|
let read = false;
|
|
let setVar = false;
|
|
let vKey = 'tags';
|
|
let vVal = '';
|
|
let cType = 'text';
|
|
|
|
rule.actions?.forEach((a) => {
|
|
if (a.type === 'readability_scrape') scrape = true;
|
|
if (a.type === 'mark_starred') starred = true;
|
|
if (a.type === 'mark_read') read = true;
|
|
if (a.type === 'set_variant') {
|
|
setVar = true;
|
|
vKey = String(a.params?.variant_key || a.params?.key || 'tags');
|
|
vVal = String(a.params?.value || '');
|
|
cType = String(a.params?.content_type || 'text');
|
|
}
|
|
});
|
|
|
|
setBuilderActions({
|
|
scrapeFullText: scrape,
|
|
markStarred: starred,
|
|
markRead: read,
|
|
setVariant: setVar,
|
|
variantKey: vKey,
|
|
variantValue: vVal,
|
|
contentType: cType,
|
|
});
|
|
|
|
setShowRuleModal(true);
|
|
};
|
|
|
|
const handleSaveRule = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!ruleName.trim()) {
|
|
alert('Please provide a descriptive rule name.');
|
|
return;
|
|
}
|
|
|
|
// Compile Conditions
|
|
const compiledConditions: RuleCondition[] = builderConditions.map((cond) => {
|
|
let finalVal: any = cond.value;
|
|
if (cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in')) {
|
|
finalVal = cond.selectedFeedIds;
|
|
} else if (cond.field === 'category_id' && (cond.operator === 'in' || cond.operator === 'not_in')) {
|
|
finalVal = cond.selectedCatIds;
|
|
}
|
|
return {
|
|
field: cond.field,
|
|
operator: cond.operator,
|
|
value: finalVal,
|
|
};
|
|
});
|
|
|
|
// Compile Actions
|
|
const compiledActions: RuleAction[] = [];
|
|
if (builderActions.scrapeFullText) {
|
|
compiledActions.push({ type: 'readability_scrape' });
|
|
}
|
|
if (builderActions.markStarred) {
|
|
compiledActions.push({ type: 'mark_starred' });
|
|
}
|
|
if (builderActions.markRead) {
|
|
compiledActions.push({ type: 'mark_read' });
|
|
}
|
|
if (builderActions.setVariant && builderActions.variantKey.trim()) {
|
|
compiledActions.push({
|
|
type: 'set_variant',
|
|
params: {
|
|
variant_key: builderActions.variantKey.trim(),
|
|
value: builderActions.variantValue,
|
|
content_type: builderActions.contentType || 'text',
|
|
},
|
|
});
|
|
}
|
|
|
|
if (compiledActions.length === 0) {
|
|
alert('Please select at least one action to execute.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (editingRuleId) {
|
|
await api.updateRule(editingRuleId, {
|
|
name: ruleName.trim(),
|
|
trigger: ruleTrigger,
|
|
match_mode: ruleMatchMode,
|
|
conditions: compiledConditions,
|
|
actions: compiledActions,
|
|
is_active: true,
|
|
});
|
|
setMessage(`Rule "${ruleName}" updated successfully.`);
|
|
} else {
|
|
await api.createRule({
|
|
name: ruleName.trim(),
|
|
trigger: ruleTrigger,
|
|
match_mode: ruleMatchMode,
|
|
conditions: compiledConditions,
|
|
actions: compiledActions,
|
|
});
|
|
setMessage(`Rule "${ruleName}" created successfully.`);
|
|
}
|
|
setShowRuleModal(false);
|
|
loadRules();
|
|
setTimeout(() => setMessage(null), 3000);
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to save 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');
|
|
}
|
|
};
|
|
|
|
const getNaturalLanguageSummary = (rule: Rule) => {
|
|
const triggerText = rule.trigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Ingested';
|
|
const condTexts: string[] = [];
|
|
|
|
if (rule.conditions && rule.conditions.length > 0) {
|
|
rule.conditions.forEach((c) => {
|
|
if (c.field === 'feed_id') {
|
|
const ids = Array.isArray(c.value) ? c.value : String(c.value).split(',');
|
|
if (c.operator === 'in') {
|
|
condTexts.push(`in ${ids.length} Feeds`);
|
|
} else if (c.operator === 'not_in') {
|
|
condTexts.push(`except ${ids.length} Feeds`);
|
|
} else if (c.operator === 'eq' || c.operator === '==') {
|
|
const f = feeds.find((item) => item.id === c.value);
|
|
condTexts.push(`in feed "${f?.title || c.value}"`);
|
|
} else {
|
|
condTexts.push(`feed ${c.operator} "${c.value}"`);
|
|
}
|
|
} else if (c.field === 'category_id') {
|
|
condTexts.push(`Category ${c.operator} "${c.value}"`);
|
|
} else if (c.field === 'title') {
|
|
condTexts.push(`Title ${c.operator.replace('_', ' ')} "${c.value}"`);
|
|
} else if (c.field === 'content' || c.field === 'raw_content') {
|
|
condTexts.push(`Content ${c.operator.replace('_', ' ')} "${c.value}"`);
|
|
} else if (c.field.startsWith('variants.')) {
|
|
condTexts.push(`${c.field} ${c.operator.replace('_', ' ')} "${c.value || ''}"`);
|
|
} else {
|
|
condTexts.push(`${c.field} ${c.operator} "${c.value}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
const condJoined =
|
|
condTexts.length > 0
|
|
? rule.match_mode === 'any'
|
|
? condTexts.join(' OR ')
|
|
: condTexts.join(' AND ')
|
|
: 'All Feeds (Global)';
|
|
|
|
const actionTexts: string[] = [];
|
|
rule.actions?.forEach((a) => {
|
|
if (a.type === 'readability_scrape') actionTexts.push('Scrape Full Text');
|
|
else if (a.type === 'mark_starred') actionTexts.push('Star');
|
|
else if (a.type === 'mark_read') actionTexts.push('Mark Read');
|
|
else if (a.type === 'set_variant') actionTexts.push(`Set Variant [${a.params?.variant_key || 'tags'}]`);
|
|
else actionTexts.push(a.type);
|
|
});
|
|
|
|
return {
|
|
trigger: triggerText,
|
|
conditions: condJoined,
|
|
actions: actionTexts.join(', ') || 'No Action',
|
|
};
|
|
};
|
|
|
|
// Filter feeds
|
|
const filteredFeeds = feeds.filter(
|
|
(f) =>
|
|
f.title.toLowerCase().includes(feedSearch.toLowerCase()) ||
|
|
f.feed_url.toLowerCase().includes(feedSearch.toLowerCase()) ||
|
|
(f.category_title && f.category_title.toLowerCase().includes(feedSearch.toLowerCase()))
|
|
);
|
|
|
|
const openEditModal = (feed: Feed) => {
|
|
setEditingFeed(feed);
|
|
setEditTitle(feed.title);
|
|
setEditCategoryId(feed.category_id || '');
|
|
setEditInterval(feed.refresh_interval_minutes || 30);
|
|
setEditTimeout(feed.timeout_seconds || 10);
|
|
};
|
|
|
|
const handleSaveFeed = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!editingFeed) return;
|
|
setEditSaving(true);
|
|
try {
|
|
await api.updateFeed(editingFeed.id, {
|
|
title: editTitle.trim() || undefined,
|
|
category_id: editCategoryId || '',
|
|
refresh_interval_minutes: Number(editInterval),
|
|
timeout_seconds: Number(editTimeout),
|
|
});
|
|
setEditingFeed(null);
|
|
onRefreshData();
|
|
setMessage(`Updated "${editTitle || editingFeed.title}" settings.`);
|
|
setTimeout(() => setMessage(null), 3500);
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to update feed');
|
|
} finally {
|
|
setEditSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateCategory = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newCatTitle.trim()) return;
|
|
setLoading(true);
|
|
try {
|
|
await api.createCategory(newCatTitle.trim());
|
|
setNewCatTitle('');
|
|
onRefreshData();
|
|
setMessage(`Category "${newCatTitle}" created.`);
|
|
setTimeout(() => setMessage(null), 3000);
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to create category');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!deleteConfirm) return;
|
|
try {
|
|
if (deleteConfirm.type === 'feed') {
|
|
await api.deleteFeed(deleteConfirm.id);
|
|
setMessage(`Unsubscribed from "${deleteConfirm.title}".`);
|
|
} else {
|
|
await api.deleteCategory(deleteConfirm.id);
|
|
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
|
}
|
|
onRefreshData();
|
|
setTimeout(() => setMessage(null), 3000);
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to delete');
|
|
} finally {
|
|
setDeleteConfirm(null);
|
|
}
|
|
};
|
|
|
|
const handleRefreshFeed = async (feedId: string) => {
|
|
try {
|
|
await api.refreshFeed(feedId);
|
|
onRefreshData();
|
|
setMessage('Feed refreshed successfully.');
|
|
setTimeout(() => setMessage(null), 2500);
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to refresh feed');
|
|
}
|
|
};
|
|
|
|
const handleImportOPML = async () => {
|
|
if (!opmlFile) return;
|
|
setOpmlLoading(true);
|
|
setOpmlResult(null);
|
|
try {
|
|
const res = await api.importOPML(opmlFile);
|
|
setOpmlResult(`Successfully imported ${res.imported_count} new feeds (out of ${res.total_parsed} parsed).`);
|
|
onRefreshData();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to import OPML');
|
|
} finally {
|
|
setOpmlLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleExportOPML = () => {
|
|
window.open(api.exportOPMLUrl(), '_blank');
|
|
};
|
|
|
|
return (
|
|
<div className="settings-container">
|
|
{/* Settings Tab Navigation (Responsive sidebar on desktop / top chips on mobile) */}
|
|
<div className="settings-nav">
|
|
<button
|
|
className={`settings-nav-item ${activeTab === 'feeds' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('feeds')}
|
|
>
|
|
<Rss size={15} />
|
|
<span>Subscriptions ({feeds.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`settings-nav-item ${activeTab === 'categories' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('categories')}
|
|
>
|
|
<Folder size={15} />
|
|
<span>Categories ({categories.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`settings-nav-item ${activeTab === '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')}
|
|
>
|
|
<Eye size={15} />
|
|
<span>Viewer</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`settings-nav-item ${activeTab === 'opml' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('opml')}
|
|
>
|
|
<FileSpreadsheet size={15} />
|
|
<span>OPML Migration</span>
|
|
</button>
|
|
|
|
<button
|
|
className={`settings-nav-item ${activeTab === 'account' ? 'active' : ''}`}
|
|
onClick={() => setActiveTab('account')}
|
|
>
|
|
<Shield size={15} />
|
|
<span>Account & System</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Main Settings Content Area */}
|
|
<div className="settings-content-area">
|
|
{message && (
|
|
<div
|
|
style={{
|
|
background: '#f0fdf4',
|
|
border: '1px solid #bbf7d0',
|
|
color: 'var(--success)',
|
|
padding: '0.65rem 1rem',
|
|
borderRadius: 6,
|
|
marginBottom: '1.25rem',
|
|
fontSize: '0.85rem',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<CheckCircle2 size={16} />
|
|
<span>{message}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 1. Subscriptions Management Tab */}
|
|
{activeTab === 'feeds' && (
|
|
<div>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: '1.25rem',
|
|
flexWrap: 'wrap',
|
|
gap: '0.75rem',
|
|
}}
|
|
>
|
|
<div>
|
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
|
Subscription Management
|
|
</h2>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
Configure background pulling interval, request timeouts, and categories per feed.
|
|
</p>
|
|
</div>
|
|
|
|
<button className="btn-primary" onClick={onOpenAddFeed}>
|
|
<Plus size={14} />
|
|
<span>Add Feed</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="settings-card" style={{ padding: '0.85rem' }}>
|
|
<div style={{ position: 'relative', marginBottom: '0.85rem' }}>
|
|
<Search
|
|
size={14}
|
|
style={{
|
|
position: 'absolute',
|
|
left: '0.75rem',
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
color: 'var(--text-muted)',
|
|
}}
|
|
/>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
style={{ paddingLeft: '2.2rem' }}
|
|
placeholder="Filter by title, URL or category..."
|
|
value={feedSearch}
|
|
onChange={(e) => setFeedSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left', color: 'var(--text-muted)' }}>
|
|
<th style={{ padding: '0.5rem 0.65rem' }}>FEED</th>
|
|
<th style={{ padding: '0.5rem 0.65rem' }}>CATEGORY</th>
|
|
<th style={{ padding: '0.5rem 0.65rem' }}>SCHEDULE</th>
|
|
<th style={{ padding: '0.5rem 0.65rem' }}>STATUS</th>
|
|
<th style={{ padding: '0.5rem 0.65rem', textAlign: 'right' }}>ACTIONS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredFeeds.map((feed) => (
|
|
<tr key={feed.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
|
|
<td style={{ padding: '0.65rem 0.65rem', maxWidth: 260 }}>
|
|
<div style={{ fontWeight: 600, color: 'var(--text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{feed.title}
|
|
</div>
|
|
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{feed.feed_url}
|
|
</div>
|
|
</td>
|
|
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
|
{feed.category_title ? (
|
|
<span className="tag-badge">{feed.category_title}</span>
|
|
) : (
|
|
<span style={{ color: 'var(--text-dim)' }}>—</span>
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: '0.72rem', color: 'var(--text-secondary)' }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
|
<Clock size={11} /> {feed.refresh_interval_minutes || 30}m
|
|
</span>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--text-muted)' }}>
|
|
<Timer size={11} /> {feed.timeout_seconds || 10}s
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
|
{feed.fetch_error ? (
|
|
<button
|
|
style={{
|
|
background: '#fef2f2',
|
|
border: '1px solid #fecaca',
|
|
color: 'var(--danger)',
|
|
borderRadius: 4,
|
|
padding: '2px 6px',
|
|
fontSize: '0.72rem',
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 3,
|
|
}}
|
|
onClick={() => setDebugFeed(feed)}
|
|
title="Click to view error details"
|
|
>
|
|
<AlertCircle size={11} /> Error Logs
|
|
</button>
|
|
) : (
|
|
<span style={{ color: 'var(--success)', fontSize: '0.72rem', fontWeight: 600 }}>
|
|
● Healthy
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '0.65rem 0.65rem', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 3 }}>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: 28, height: 28 }}
|
|
title="Edit schedule and settings"
|
|
onClick={() => openEditModal(feed)}
|
|
>
|
|
<Edit2 size={13} />
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: 28, height: 28 }}
|
|
title="Fetch Now"
|
|
onClick={() => handleRefreshFeed(feed.id)}
|
|
>
|
|
<RefreshCw size={13} />
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: 28, height: 28, color: 'var(--danger)' }}
|
|
title="Unsubscribe"
|
|
onClick={() =>
|
|
setDeleteConfirm({
|
|
type: 'feed',
|
|
id: feed.id,
|
|
title: feed.title,
|
|
})
|
|
}
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 2. Categories Tab */}
|
|
{activeTab === 'categories' && (
|
|
<div>
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
|
Category Management
|
|
</h2>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
Create, organize, and delete category folders.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Create Category */}
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Add New Category</h3>
|
|
<form onSubmit={handleCreateCategory} style={{ display: 'flex', gap: '0.6rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="Category name (e.g. AI Research, Finance)..."
|
|
value={newCatTitle}
|
|
onChange={(e) => setNewCatTitle(e.target.value)}
|
|
style={{ maxWidth: 360 }}
|
|
required
|
|
/>
|
|
<button type="submit" className="btn-primary" disabled={loading}>
|
|
<Plus size={14} />
|
|
<span>Create Category</span>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Existing Categories */}
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Existing Categories ({categories.length})</h3>
|
|
<div style={{ marginTop: '0.6rem' }}>
|
|
{categories.map((cat) => (
|
|
<div
|
|
key={cat.id}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '0.65rem 0',
|
|
borderBottom: '1px solid var(--border-light)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem' }}>
|
|
<Folder size={15} style={{ color: 'var(--text-muted)' }} />
|
|
<span style={{ fontWeight: 600, color: 'var(--text-main)' }}>{cat.title}</span>
|
|
<span className="badge">{cat.feed_count || 0} feeds</span>
|
|
</div>
|
|
|
|
<button
|
|
className="btn-icon"
|
|
style={{ color: 'var(--danger)' }}
|
|
onClick={() =>
|
|
setDeleteConfirm({
|
|
type: 'category',
|
|
id: cat.id,
|
|
title: cat.title,
|
|
})
|
|
}
|
|
title="Delete Category"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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, Tagging) on post arrival or variant updates.
|
|
</p>
|
|
</div>
|
|
|
|
<button className="btn-primary" onClick={openCreateRuleModal}>
|
|
<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: 420, margin: '0.5rem auto 1.5rem auto' }}>
|
|
Create rules to automatically scrape full-text articles across your favorite subscriptions, tag topics, or auto-triage feeds.
|
|
</p>
|
|
<button className="btn-primary" onClick={openCreateRuleModal}>
|
|
<Plus size={14} />
|
|
<span>Create Your First Rule</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
{rules.map((rule) => {
|
|
const summary = getNaturalLanguageSummary(rule);
|
|
return (
|
|
<div key={rule.id} className="settings-card" style={{ padding: '1.1rem 1.25rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.85rem', flex: 1 }}>
|
|
<div
|
|
style={{
|
|
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)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
<Zap size={17} />
|
|
</div>
|
|
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<span style={{ fontWeight: 700, fontSize: '0.95rem', color: 'var(--text-main)' }}>
|
|
{rule.name}
|
|
</span>
|
|
<span className="tag-badge" style={{ fontSize: '0.68rem', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
|
{summary.trigger}
|
|
</span>
|
|
{!rule.is_active && (
|
|
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 500 }}>(Disabled)</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Natural Language Sentence Preview */}
|
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-secondary)', marginTop: '0.45rem', lineHeight: 1.45 }}>
|
|
When <strong>{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-muted)' }}>➜ Then</span>{' '}
|
|
<span style={{ color: 'var(--success)', fontWeight: 600 }}>{summary.actions}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexShrink: 0 }}>
|
|
<button
|
|
className="btn-secondary"
|
|
style={{ fontSize: '0.72rem', padding: '0.3rem 0.65rem' }}
|
|
onClick={() => handleToggleRuleActive(rule)}
|
|
>
|
|
{rule.is_active ? 'Disable' : 'Enable'}
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: 28, height: 28 }}
|
|
title="Edit Rule"
|
|
onClick={() => openEditRuleModal(rule)}
|
|
>
|
|
<Edit2 size={13} />
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
style={{ width: 28, height: 28, color: 'var(--danger)' }}
|
|
onClick={() => handleDeleteRule(rule.id)}
|
|
title="Delete Rule"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 3. Viewer Preferences Tab */}
|
|
{activeTab === 'viewer' && (
|
|
<div>
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
|
Viewer & Reading Preferences
|
|
</h2>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
Configure reading behavior, auto-read triggers, and article view options.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Auto-Mark as Read</h3>
|
|
<p className="settings-card-subtitle">
|
|
Control how articles are automatically marked as read during your reading session.
|
|
</p>
|
|
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem', cursor: 'pointer' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={autoReadBottom}
|
|
onChange={(e) => {
|
|
const val = e.target.checked;
|
|
setAutoReadBottom(val);
|
|
localStorage.setItem('rsspipe_auto_read_bottom', val ? 'true' : 'false');
|
|
setMessage(`Updated viewer preferences: auto-read at bottom ${val ? 'enabled' : 'disabled'}.`);
|
|
setTimeout(() => setMessage(null), 3000);
|
|
}}
|
|
style={{ marginTop: '0.2rem', width: 16, height: 16, accentColor: 'var(--accent)' }}
|
|
/>
|
|
<div>
|
|
<div style={{ fontSize: '0.88rem', fontWeight: 600, color: 'var(--text-main)' }}>
|
|
Auto mark short articles (< 1 page) as read after 2 seconds
|
|
</div>
|
|
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: '0.25rem', lineHeight: 1.4 }}>
|
|
• <strong>Short articles (< 1 page)</strong>: Since the content is non-scrollable and fully visible, automatically marks as read after 2 seconds of viewing.<br />
|
|
• <strong>Long articles (≥ 1 page)</strong>: Any active scroll on the content pane immediately marks the post as read.
|
|
</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 4. OPML Migration Tab */}
|
|
{activeTab === 'opml' && (
|
|
<div>
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
|
OPML Migration & Backup
|
|
</h2>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
Easily import from external RSS services or download a complete backup.
|
|
</p>
|
|
</div>
|
|
|
|
{opmlResult && (
|
|
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: 'var(--success)', padding: '0.85rem 1rem', borderRadius: 6, marginBottom: '1.25rem', fontSize: '0.85rem' }}>
|
|
{opmlResult}
|
|
</div>
|
|
)}
|
|
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Import Subscriptions</h3>
|
|
<p className="settings-card-subtitle">
|
|
Select an OPML XML file exported from Miniflux, Feedly, or NetNewsWire.
|
|
</p>
|
|
|
|
<div
|
|
style={{
|
|
border: '2px dashed var(--border-dark)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
padding: '2rem 1rem',
|
|
textAlign: 'center',
|
|
background: 'var(--bg-app)',
|
|
cursor: 'pointer',
|
|
marginBottom: '1rem',
|
|
}}
|
|
onClick={() => document.getElementById('settings-opml-file')?.click()}
|
|
>
|
|
<input
|
|
id="settings-opml-file"
|
|
type="file"
|
|
accept=".opml,.xml"
|
|
style={{ display: 'none' }}
|
|
onChange={(e) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
setOpmlFile(e.target.files[0]);
|
|
}
|
|
}}
|
|
/>
|
|
<UploadCloud size={30} style={{ color: 'var(--text-muted)', margin: '0 auto 0.5rem auto' }} />
|
|
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
|
|
{opmlFile ? opmlFile.name : 'Click to select OPML file'}
|
|
</div>
|
|
</div>
|
|
|
|
{opmlFile && (
|
|
<button
|
|
className="btn-primary"
|
|
onClick={handleImportOPML}
|
|
disabled={opmlLoading}
|
|
>
|
|
{opmlLoading ? (
|
|
<>
|
|
<Loader2 size={14} className="animate-spin" /> Importing...
|
|
</>
|
|
) : (
|
|
<>Import {opmlFile.name}</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Export Subscriptions</h3>
|
|
<p className="settings-card-subtitle">
|
|
Download an OPML 2.0 XML file containing all your {feeds.length} subscribed feeds and folder trees.
|
|
</p>
|
|
|
|
<button className="btn-secondary" onClick={handleExportOPML}>
|
|
<Download size={14} />
|
|
<span>Export OPML Backup</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 4. Account & System Tab */}
|
|
{activeTab === 'account' && (
|
|
<div>
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
|
Account & System Health
|
|
</h2>
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
Single-user gateway credentials and storage metrics.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Admin Profile</h3>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
|
<div style={{ marginBottom: '0.4rem' }}>
|
|
<strong>Username:</strong> {currentUser.username}
|
|
</div>
|
|
<div style={{ marginBottom: '0.4rem' }}>
|
|
<strong>User ID:</strong> <code>{currentUser.id}</code>
|
|
</div>
|
|
<div>
|
|
<strong>Access Mode:</strong> Single-User RSS Information Gateway
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<Lock size={15} style={{ color: 'var(--accent)' }} />
|
|
<span>Change Password</span>
|
|
</h3>
|
|
<p className="settings-card-subtitle">
|
|
Update your single-user administrator password. Minimum 6 characters required.
|
|
</p>
|
|
|
|
{passwordSuccess && (
|
|
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: 'var(--success)', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<CheckCircle2 size={16} />
|
|
<span>{passwordSuccess}</span>
|
|
</div>
|
|
)}
|
|
|
|
{passwordError && (
|
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', color: 'var(--danger)', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<AlertCircle size={16} />
|
|
<span>{passwordError}</span>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleChangePassword} style={{ maxWidth: 420, marginTop: '0.75rem' }}>
|
|
<div className="form-group">
|
|
<label className="form-label">Current Password</label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="Enter current password"
|
|
value={oldPassword}
|
|
onChange={(e) => setOldPassword(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">New Password</label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="Enter new password (min. 6 characters)"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">Confirm New Password</label>
|
|
<input
|
|
type="password"
|
|
className="form-input"
|
|
placeholder="Re-enter new password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
className="btn-primary"
|
|
disabled={passwordLoading}
|
|
style={{ marginTop: '0.5rem' }}
|
|
>
|
|
{passwordLoading ? (
|
|
<>
|
|
<Loader2 size={14} className="animate-spin" />
|
|
<span>Updating Password...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Key size={14} />
|
|
<span>Update Password</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="settings-card">
|
|
<h3 className="settings-card-title">Storage & Engine Stats</h3>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
|
<div style={{ marginBottom: '0.4rem' }}>
|
|
<strong>Database Engine:</strong> SQLite 3 with WAL (Write-Ahead Logging) Mode
|
|
</div>
|
|
<div style={{ marginBottom: '0.4rem' }}>
|
|
<strong>Total Subscribed Feeds:</strong> {stats?.total_feeds || feeds.length}
|
|
</div>
|
|
<div style={{ marginBottom: '0.4rem' }}>
|
|
<strong>Total Unread Articles:</strong> {stats?.total_unread || 0}
|
|
</div>
|
|
<div>
|
|
<strong>Total Starred Articles:</strong> {stats?.total_starred || 0}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Feed Modal (Pulling interval, timeout, category, title) */}
|
|
{editingFeed && (
|
|
<div className="modal-overlay" onClick={() => setEditingFeed(null)}>
|
|
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3>Edit Subscription Settings</h3>
|
|
<button className="btn-icon" onClick={() => setEditingFeed(null)}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSaveFeed}>
|
|
<div className="modal-body">
|
|
<div className="form-group">
|
|
<label className="form-label">Feed Title</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">Feed URL</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={editingFeed.feed_url}
|
|
disabled
|
|
style={{ background: 'var(--bg-app)', color: 'var(--text-muted)' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">Category</label>
|
|
<select
|
|
className="form-select"
|
|
value={editCategoryId}
|
|
onChange={(e) => setEditCategoryId(e.target.value)}
|
|
>
|
|
<option value="">(Uncategorized)</option>
|
|
{categories.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
|
<div className="form-group">
|
|
<label className="form-label">Pull Interval</label>
|
|
<select
|
|
className="form-select"
|
|
value={editInterval}
|
|
onChange={(e) => setEditInterval(Number(e.target.value))}
|
|
>
|
|
<option value={5}>Every 5 minutes</option>
|
|
<option value={15}>Every 15 minutes</option>
|
|
<option value={30}>Every 30 minutes</option>
|
|
<option value={60}>Every 1 hour</option>
|
|
<option value={120}>Every 2 hours</option>
|
|
<option value={360}>Every 6 hours</option>
|
|
<option value={1440}>Every 24 hours</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label className="form-label">Request Timeout</label>
|
|
<select
|
|
className="form-select"
|
|
value={editTimeout}
|
|
onChange={(e) => setEditTimeout(Number(e.target.value))}
|
|
>
|
|
<option value={5}>5 seconds (Fast)</option>
|
|
<option value={10}>10 seconds (Standard)</option>
|
|
<option value={15}>15 seconds</option>
|
|
<option value={30}>30 seconds (Slow)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
onClick={() => setEditingFeed(null)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn-primary" disabled={editSaving}>
|
|
{editSaving ? 'Saving...' : 'Save Settings'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Debug Logs Modal (Persistent error inspector) */}
|
|
{debugFeed && (
|
|
<div className="modal-overlay" onClick={() => setDebugFeed(null)}>
|
|
<div className="modal-card" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<AlertCircle size={17} style={{ color: 'var(--danger)' }} />
|
|
<h3>Feed Fetch Diagnostics</h3>
|
|
</div>
|
|
<button className="btn-icon" onClick={() => setDebugFeed(null)}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="modal-body">
|
|
<div style={{ marginBottom: '0.75rem' }}>
|
|
<strong>Feed:</strong> {debugFeed.title}
|
|
</div>
|
|
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)', wordBreak: 'break-all' }}>
|
|
<strong>URL:</strong> {debugFeed.feed_url}
|
|
</div>
|
|
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
<label className="form-label">Error Log Output</label>
|
|
<pre
|
|
style={{
|
|
background: '#fef2f2',
|
|
border: '1px solid #fecaca',
|
|
color: 'var(--danger)',
|
|
padding: '0.85rem',
|
|
borderRadius: 6,
|
|
fontSize: '0.78rem',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-all',
|
|
maxHeight: 200,
|
|
overflowY: 'auto',
|
|
}}
|
|
>
|
|
{debugFeed.fetch_error || 'No error recorded.'}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<button
|
|
className="btn-secondary"
|
|
onClick={() => {
|
|
handleRefreshFeed(debugFeed.id);
|
|
setDebugFeed(null);
|
|
}}
|
|
>
|
|
<RefreshCw size={13} />
|
|
<span>Retry Fetch Now</span>
|
|
</button>
|
|
|
|
<button className="btn-primary" onClick={() => setDebugFeed(null)}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Solid Confirmation Modal for Deletion (Never auto-dismisses) */}
|
|
{deleteConfirm && (
|
|
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
|
|
<div className="modal-card" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h3 style={{ color: 'var(--danger)' }}>Confirm Deletion</h3>
|
|
<button className="btn-icon" onClick={() => setDeleteConfirm(null)}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="modal-body" style={{ fontSize: '0.85rem', lineHeight: 1.5 }}>
|
|
Are you sure you want to {deleteConfirm.type === 'feed' ? 'unsubscribe from' : 'delete category'}{' '}
|
|
<strong>"{deleteConfirm.title}"</strong>?
|
|
{deleteConfirm.type === 'category' && (
|
|
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)', fontSize: '0.78rem' }}>
|
|
Feeds inside this category will remain and become uncategorized.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
|
<button className="btn-secondary" onClick={() => setDeleteConfirm(null)}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn-primary"
|
|
style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }}
|
|
onClick={handleConfirmDelete}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
<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' }}>
|
|
{/* Rule Name */}
|
|
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
|
|
<label className="form-label" style={{ fontWeight: 600 }}>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
|
|
/>
|
|
</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={{ 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>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
|
<label
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
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)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="rule_trigger"
|
|
checked={ruleTrigger === 'on_post_created'}
|
|
onChange={() => setRuleTrigger('on_post_created')}
|
|
/>
|
|
<div>
|
|
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>New Post Ingested</div>
|
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Trigger on new RSS arrival</div>
|
|
</div>
|
|
</label>
|
|
|
|
<label
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
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)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="rule_trigger"
|
|
checked={ruleTrigger === 'on_variant_updated'}
|
|
onChange={() => setRuleTrigger('on_variant_updated')}
|
|
/>
|
|
<div>
|
|
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>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>
|
|
<label className="form-label" style={{ fontSize: '0.75rem' }}>Sub-Event Type</label>
|
|
<select
|
|
className="form-select"
|
|
value={variantSubEvent}
|
|
onChange={(e) => setVariantSubEvent(e.target.value as any)}
|
|
>
|
|
<option value="key_added">Key Added / Initialized</option>
|
|
<option value="value_changed">Value Changed</option>
|
|
<option value="key_deleted">Key Deleted</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="form-label" style={{ fontSize: '0.75rem' }}>Target Variant Key</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="e.g. keywords, readability_full_text"
|
|
value={targetVariantKey}
|
|
onChange={(e) => setTargetVariantKey(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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={{ 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>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.75rem' }}>
|
|
<span style={{ color: 'var(--text-muted)' }}>Match:</span>
|
|
<select
|
|
className="form-select"
|
|
style={{ padding: '0.2rem 0.5rem', fontSize: '0.75rem', width: 'auto' }}
|
|
value={ruleMatchMode}
|
|
onChange={(e) => setRuleMatchMode(e.target.value as any)}
|
|
>
|
|
<option value="all">All Conditions (AND)</option>
|
|
<option value="any">Any Condition (OR)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<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' }}>
|
|
{/* Field Selector */}
|
|
<select
|
|
className="form-select"
|
|
value={cond.field}
|
|
onChange={(e) => {
|
|
const newField = e.target.value;
|
|
let defaultOp = 'eq';
|
|
if (newField === 'feed_id' || newField === 'category_id') defaultOp = 'in';
|
|
else if (newField === 'title' || newField === 'content') defaultOp = 'contains_any';
|
|
else if (newField === 'published_at') defaultOp = 'within_last_hours';
|
|
else if (newField.startsWith('variants.')) defaultOp = 'exists';
|
|
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, field: newField, operator: defaultOp };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
<option value="feed_id">Feed Scope (feed_id)</option>
|
|
<option value="category_id">Category (category_id)</option>
|
|
<option value="title">Article Title (title)</option>
|
|
<option value="content">Article Content (content)</option>
|
|
<option value="author">Author (author)</option>
|
|
<option value="published_at">Publish Date (published_at)</option>
|
|
<option value="is_read">Read Status (is_read)</option>
|
|
<option value="is_starred">Star Status (is_starred)</option>
|
|
<option value="variants.keywords">Keywords (variants.keywords)</option>
|
|
<option value="variants.readability_full_text">Full Text (variants.readability)</option>
|
|
<option value="variants.tags">Tags (variants.tags)</option>
|
|
</select>
|
|
|
|
{/* Contextual Operator Selector */}
|
|
<select
|
|
className="form-select"
|
|
value={cond.operator}
|
|
onChange={(e) => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, operator: e.target.value };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
{cond.field === 'feed_id' && (
|
|
<>
|
|
<option value="in">In Selected Feeds</option>
|
|
<option value="not_in">Except Selected Feeds</option>
|
|
<option value="eq">Single Feed (==)</option>
|
|
<option value="neq">Not Feed (!=)</option>
|
|
</>
|
|
)}
|
|
{cond.field === 'category_id' && (
|
|
<>
|
|
<option value="in">In Selected Categories</option>
|
|
<option value="not_in">Except Selected Categories</option>
|
|
<option value="eq">Single Category (==)</option>
|
|
<option value="neq">Not Category (!=)</option>
|
|
</>
|
|
)}
|
|
{(cond.field === 'title' || cond.field === 'content' || cond.field === 'author') && (
|
|
<>
|
|
<option value="contains_any">Contains Any Keyword</option>
|
|
<option value="contains">Contains Substring</option>
|
|
<option value="not_contains">Does Not Contain</option>
|
|
<option value="starts_with">Starts With</option>
|
|
<option value="ends_with">Ends With</option>
|
|
<option value="matches">Regex Match</option>
|
|
<option value="eq">Equals (==)</option>
|
|
</>
|
|
)}
|
|
{cond.field === 'published_at' && (
|
|
<>
|
|
<option value="within_last_hours">Within Last X Hours</option>
|
|
<option value="older_than_days">Older Than X Days</option>
|
|
<option value=">=">After (>=)</option>
|
|
<option value="<=">Before (<=)</option>
|
|
</>
|
|
)}
|
|
{(cond.field === 'is_read' || cond.field === 'is_starred') && (
|
|
<>
|
|
<option value="eq">Is (==)</option>
|
|
<option value="neq">Is Not (!=)</option>
|
|
</>
|
|
)}
|
|
{cond.field.startsWith('variants.') && (
|
|
<>
|
|
<option value="exists">Exists / Non-Empty</option>
|
|
<option value="not_exists">Does Not Exist</option>
|
|
<option value="contains">Contains Substring</option>
|
|
<option value="eq">Equals (==)</option>
|
|
</>
|
|
)}
|
|
</select>
|
|
|
|
{/* Delete condition button */}
|
|
{builderConditions.length > 1 && (
|
|
<button
|
|
type="button"
|
|
className="btn-icon"
|
|
style={{ color: 'var(--danger)' }}
|
|
onClick={() => {
|
|
setBuilderConditions(builderConditions.filter((_, i) => i !== idx));
|
|
}}
|
|
title="Remove condition"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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 }}>
|
|
<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)' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
style={{ fontSize: '0.7rem', padding: '0.2rem 0.45rem' }}
|
|
onClick={() => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, selectedFeedIds: feeds.map((f) => f.id) };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
Select All
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
style={{ fontSize: '0.7rem', padding: '0.2rem 0.45rem' }}
|
|
onClick={() => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, selectedFeedIds: [] };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
None
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
style={{ fontSize: '0.7rem', padding: '0.2rem 0.45rem' }}
|
|
onClick={() => {
|
|
const curr = new Set(cond.selectedFeedIds);
|
|
const inverted = feeds.filter((f) => !curr.has(f.id)).map((f) => f.id);
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, selectedFeedIds: inverted };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
Invert
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: '0.72rem', color: '#0284c7', 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' }}>
|
|
{feeds
|
|
.filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase()))
|
|
.map((feed) => {
|
|
const isChecked = cond.selectedFeedIds.includes(feed.id);
|
|
return (
|
|
<label
|
|
key={feed.id}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.4rem',
|
|
padding: '0.3rem 0.45rem',
|
|
borderRadius: 4,
|
|
background: isChecked ? 'rgba(2, 132, 199, 0.08)' : 'transparent',
|
|
fontSize: '0.75rem',
|
|
cursor: 'pointer',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={() => {
|
|
const next = isChecked
|
|
? cond.selectedFeedIds.filter((id) => id !== feed.id)
|
|
: [...cond.selectedFeedIds, feed.id];
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, selectedFeedIds: next };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
style={{ accentColor: '#0284c7' }}
|
|
/>
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isChecked ? 600 : 400 }}>
|
|
{feed.title}
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : cond.field === 'feed_id' ? (
|
|
<select
|
|
className="form-select"
|
|
value={cond.value}
|
|
onChange={(e) => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, value: e.target.value };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
<option value="">Select Subscribed Feed...</option>
|
|
{feeds.map((f) => (
|
|
<option key={f.id} value={f.id}>
|
|
{f.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : cond.field === 'category_id' ? (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
|
{categories.map((cat) => {
|
|
const isChecked = cond.selectedCatIds.includes(cat.id);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={cat.id}
|
|
onClick={() => {
|
|
const next = isChecked
|
|
? cond.selectedCatIds.filter((id) => id !== cat.id)
|
|
: [...cond.selectedCatIds, cat.id];
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, selectedCatIds: next };
|
|
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)',
|
|
padding: '0.2rem 0.6rem',
|
|
borderRadius: 4,
|
|
fontSize: '0.75rem',
|
|
fontWeight: isChecked ? 600 : 400,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{cat.title}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : cond.field === 'published_at' ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
placeholder="24"
|
|
value={cond.value}
|
|
onChange={(e) => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, value: e.target.value };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
style={{ width: 120 }}
|
|
/>
|
|
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
|
|
{cond.operator === 'older_than_days' ? 'days' : 'hours'}
|
|
</span>
|
|
</div>
|
|
) : cond.field === 'is_read' || cond.field === 'is_starred' ? (
|
|
<select
|
|
className="form-select"
|
|
value={cond.value || 'true'}
|
|
onChange={(e) => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, value: e.target.value };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
>
|
|
<option value="true">True (Yes)</option>
|
|
<option value="false">False (No)</option>
|
|
</select>
|
|
) : cond.operator === 'exists' || cond.operator === 'not_exists' ? (
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', fontStyle: 'italic' }}>
|
|
Checks if the variant key exists and is non-empty.
|
|
</div>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder={cond.operator === 'contains_any' ? 'e.g. AI, OpenAI, DeepSeek (comma-separated)' : 'Expected value'}
|
|
value={cond.value}
|
|
onChange={(e) => {
|
|
const updated = [...builderConditions];
|
|
updated[idx] = { ...cond, value: e.target.value };
|
|
setBuilderConditions(updated);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
type="button"
|
|
className="btn-secondary"
|
|
style={{ alignSelf: 'flex-start', fontSize: '0.75rem', padding: '0.3rem 0.65rem' }}
|
|
onClick={() => {
|
|
setBuilderConditions([
|
|
...builderConditions,
|
|
{
|
|
id: `cond-${Date.now()}`,
|
|
field: 'title',
|
|
operator: 'contains_any',
|
|
value: '',
|
|
selectedFeedIds: [],
|
|
selectedCatIds: [],
|
|
},
|
|
]);
|
|
}}
|
|
>
|
|
<Plus size={13} />
|
|
<span>Add Condition</span>
|
|
</button>
|
|
</div>
|
|
</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={{ 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>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={builderActions.scrapeFullText}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, scrapeFullText: e.target.checked })}
|
|
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
|
/>
|
|
<div>
|
|
<strong>⚡ 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>
|
|
</div>
|
|
</label>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={builderActions.markStarred}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, markStarred: e.target.checked })}
|
|
style={{ accentColor: '#0284c7', 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>
|
|
</div>
|
|
</label>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={builderActions.markRead}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, markRead: e.target.checked })}
|
|
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
|
/>
|
|
<div>
|
|
<strong>✓ Mark as Read (Auto-Archive)</strong>
|
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Automatically mark as read on ingestion.</div>
|
|
</div>
|
|
</label>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: '0.85rem' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={builderActions.setVariant}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, setVariant: e.target.checked })}
|
|
style={{ accentColor: '#0284c7', 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>
|
|
|
|
{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>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="e.g. tags"
|
|
value={builderActions.variantKey}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, variantKey: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>Value</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
placeholder="e.g. ai-digest"
|
|
value={builderActions.variantValue}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, variantValue: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>Content Type</label>
|
|
<select
|
|
className="form-select"
|
|
value={builderActions.contentType}
|
|
onChange={(e) => setBuilderActions({ ...builderActions, contentType: e.target.value })}
|
|
>
|
|
<option value="text">text</option>
|
|
<option value="json">json</option>
|
|
<option value="yaml">yaml</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</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' }}>
|
|
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>{' '}
|
|
{builderConditions.length > 0 && (
|
|
<>
|
|
<span style={{ color: 'var(--text-muted)' }}>where</span>{' '}
|
|
<span style={{ color: '#0284c7', fontWeight: 600 }}>
|
|
{builderConditions
|
|
.map((c) => {
|
|
if (c.field === 'feed_id' && (c.operator === 'in' || c.operator === 'not_in')) {
|
|
return `${c.operator === 'in' ? 'in' : 'except'} ${c.selectedFeedIds.length} feeds`;
|
|
}
|
|
return `${c.field} ${c.operator} "${c.value}"`;
|
|
})
|
|
.join(ruleMatchMode === 'any' ? ' OR ' : ' AND ')}
|
|
</span>
|
|
</>
|
|
)}
|
|
{' '}➜ Then{' '}
|
|
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
|
|
{[
|
|
builderActions.scrapeFullText && 'Scrape Full Text',
|
|
builderActions.markStarred && 'Star Post',
|
|
builderActions.markRead && 'Mark Read',
|
|
builderActions.setVariant && `Set [${builderActions.variantKey || 'tags'}]`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ') || 'No Actions Selected'}
|
|
</span>
|
|
</div>
|
|
</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)}>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" className="btn-primary">
|
|
{editingRuleId ? 'Save Rule Changes' : 'Create Automation Rule'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|