feat: implement rule application engine with full-text variant support, batch entry processing, and tag visualization in UI.
This commit is contained in:
@@ -722,3 +722,52 @@ func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ApplyRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
rule, err := h.db.GetRule(id)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "rule not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluator := rules.NewEvaluator()
|
||||||
|
matchedCount := 0
|
||||||
|
queuedJobs := 0
|
||||||
|
|
||||||
|
// Paginate through ALL entries in batches of 500
|
||||||
|
batchSize := 500
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
entries, total, err := h.db.ListEntries(db.EntryFilter{
|
||||||
|
View: "all",
|
||||||
|
Limit: batchSize,
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "failed to query entries: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if evaluator.Evaluate(*rule, entry) {
|
||||||
|
matchedCount++
|
||||||
|
for _, action := range rule.Actions {
|
||||||
|
h.pool.Dispatch(r.Context(), action, entry, 0)
|
||||||
|
queuedJobs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += batchSize
|
||||||
|
if offset >= total || len(entries) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": fmt.Sprintf("Rule applied to %d matching post(s). %d action(s) queued.", matchedCount, queuedJobs),
|
||||||
|
"matched_count": matchedCount,
|
||||||
|
"queued_jobs": queuedJobs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
|||||||
r.Post("/rules", h.CreateRule)
|
r.Post("/rules", h.CreateRule)
|
||||||
r.Put("/rules/{id}", h.UpdateRule)
|
r.Put("/rules/{id}", h.UpdateRule)
|
||||||
r.Delete("/rules/{id}", h.DeleteRule)
|
r.Delete("/rules/{id}", h.DeleteRule)
|
||||||
|
r.Post("/rules/{id}/apply", h.ApplyRule)
|
||||||
|
|
||||||
// OPML
|
// OPML
|
||||||
r.Post("/opml/import", h.ImportOPML)
|
r.Post("/opml/import", h.ImportOPML)
|
||||||
|
|||||||
@@ -725,6 +725,22 @@ func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) {
|
|||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch-load variants for all returned entries
|
||||||
|
if len(entries) > 0 {
|
||||||
|
ids := make([]string, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
ids[i] = e.ID
|
||||||
|
}
|
||||||
|
variantMap, err := d.batchLoadVariants(ids)
|
||||||
|
if err == nil {
|
||||||
|
for i := range entries {
|
||||||
|
if v, ok := variantMap[entries[i].ID]; ok {
|
||||||
|
entries[i].Variants = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return entries, total, nil
|
return entries, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,6 +826,55 @@ func (d *DB) ListVariantsForEntry(entryID string) (map[string]models.EntryVarian
|
|||||||
return variants, nil
|
return variants, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// batchLoadVariants loads lightweight variants (tags, ai_summary keys, etc.) for multiple entries.
|
||||||
|
// Excludes large text variants like readability_full_text for performance.
|
||||||
|
func (d *DB) batchLoadVariants(entryIDs []string) (map[string]map[string]models.EntryVariant, error) {
|
||||||
|
if len(entryIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
placeholders := make([]string, len(entryIDs))
|
||||||
|
args := make([]interface{}, len(entryIDs))
|
||||||
|
for i, id := range entryIDs {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT entry_id, variant_key, content_type,
|
||||||
|
CASE WHEN variant_key IN ('readability_full_text') THEN '' ELSE COALESCE(text_value, '') END,
|
||||||
|
COALESCE(blob_path, ''), metadata, created_at, updated_at
|
||||||
|
FROM entry_variants
|
||||||
|
WHERE entry_id IN (` + strings.Join(placeholders, ",") + `)
|
||||||
|
`
|
||||||
|
rows, err := d.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = rows.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
result := make(map[string]map[string]models.EntryVariant)
|
||||||
|
for rows.Next() {
|
||||||
|
var v models.EntryVariant
|
||||||
|
var metaStr sql.NullString
|
||||||
|
err := rows.Scan(
|
||||||
|
&v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v.Metadata = make(map[string]interface{})
|
||||||
|
if metaStr.Valid && metaStr.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(metaStr.String), &v.Metadata)
|
||||||
|
}
|
||||||
|
if result[v.EntryID] == nil {
|
||||||
|
result[v.EntryID] = make(map[string]models.EntryVariant)
|
||||||
|
}
|
||||||
|
result[v.EntryID][v.VariantKey] = v
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) SetVariant(v models.EntryVariant) error {
|
func (d *DB) SetVariant(v models.EntryVariant) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
metaBytes, _ := json.Marshal(v.Metadata)
|
metaBytes, _ := json.Marshal(v.Metadata)
|
||||||
|
|||||||
@@ -59,10 +59,19 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
|||||||
case "url":
|
case "url":
|
||||||
return entry.URL
|
return entry.URL
|
||||||
case "content", "raw_content", "rss_content":
|
case "content", "raw_content", "rss_content":
|
||||||
|
var parts []string
|
||||||
if entry.Content != "" {
|
if entry.Content != "" {
|
||||||
return entry.Content
|
parts = append(parts, entry.Content)
|
||||||
}
|
}
|
||||||
return entry.RawContent
|
if entry.RawContent != "" && entry.RawContent != entry.Content {
|
||||||
|
parts = append(parts, entry.RawContent)
|
||||||
|
}
|
||||||
|
if entry.Variants != nil {
|
||||||
|
if v, ok := entry.Variants["readability_full_text"]; ok && v.TextValue != "" {
|
||||||
|
parts = append(parts, v.TextValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
case "is_read":
|
case "is_read":
|
||||||
return entry.IsRead
|
return entry.IsRead
|
||||||
case "is_starred":
|
case "is_starred":
|
||||||
@@ -127,13 +136,17 @@ func (e *Evaluator) compare(actual interface{}, op string, expected interface{})
|
|||||||
case "neq", "!=":
|
case "neq", "!=":
|
||||||
return !strings.EqualFold(actualStr, expectedStr)
|
return !strings.EqualFold(actualStr, expectedStr)
|
||||||
case "contains":
|
case "contains":
|
||||||
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
||||||
|
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
||||||
case "not_contains":
|
case "not_contains":
|
||||||
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
||||||
|
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
||||||
case "starts_with":
|
case "starts_with":
|
||||||
return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
||||||
|
return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
||||||
case "ends_with":
|
case "ends_with":
|
||||||
return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
||||||
|
return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
||||||
case "contains_any":
|
case "contains_any":
|
||||||
tokens := parseStringList(expected)
|
tokens := parseStringList(expected)
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
@@ -204,19 +217,31 @@ func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bo
|
|||||||
|
|
||||||
func parseStringList(val interface{}) []string {
|
func parseStringList(val interface{}) []string {
|
||||||
var result []string
|
var result []string
|
||||||
|
clean := func(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.Trim(s, "\"'`")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
switch v := val.(type) {
|
switch v := val.(type) {
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
result = append(result, strings.TrimSpace(fmt.Sprintf("%v", item)))
|
itemStr := clean(fmt.Sprintf("%v", item))
|
||||||
|
if itemStr != "" {
|
||||||
|
result = append(result, itemStr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case []string:
|
case []string:
|
||||||
for _, item := range v {
|
for _, item := range v {
|
||||||
result = append(result, strings.TrimSpace(item))
|
itemStr := clean(item)
|
||||||
|
if itemStr != "" {
|
||||||
|
result = append(result, itemStr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case string:
|
case string:
|
||||||
parts := strings.Split(v, ",")
|
parts := strings.Split(v, ",")
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
trimmed := strings.TrimSpace(part)
|
trimmed := clean(part)
|
||||||
if trimmed != "" {
|
if trimmed != "" {
|
||||||
result = append(result, trimmed)
|
result = append(result, trimmed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Clock,
|
Clock,
|
||||||
Timer,
|
Timer,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
@@ -76,6 +77,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
const [rulesLoading, setRulesLoading] = useState(false);
|
const [rulesLoading, setRulesLoading] = useState(false);
|
||||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [saveDropdownOpen, setSaveDropdownOpen] = useState(false);
|
||||||
|
|
||||||
// Form State for Automation Rule Builder
|
// Form State for Automation Rule Builder
|
||||||
const [ruleName, setRuleName] = useState('');
|
const [ruleName, setRuleName] = useState('');
|
||||||
@@ -120,7 +123,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
|
|
||||||
// Custom Delete Confirmation Modal State
|
// Custom Delete Confirmation Modal State
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
type: 'feed' | 'category';
|
type: 'feed' | 'category' | 'rule';
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -210,6 +213,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
variantValue: '',
|
variantValue: '',
|
||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
});
|
});
|
||||||
|
setSaveDropdownOpen(false);
|
||||||
setShowRuleModal(true);
|
setShowRuleModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,14 +290,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
contentType: cType,
|
contentType: cType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSaveDropdownOpen(false);
|
||||||
setShowRuleModal(true);
|
setShowRuleModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRule = async (e: React.FormEvent) => {
|
const handleSaveRule = async (e?: React.FormEvent, applyToExisting: boolean = false) => {
|
||||||
e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
if (!ruleName.trim()) {
|
if (!ruleName.trim()) {
|
||||||
alert('Please provide a descriptive rule name.');
|
return; // Inline validation below the input field already shows the error
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile Conditions
|
// Compile Conditions
|
||||||
@@ -338,7 +342,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSaveLoading(true);
|
||||||
|
setSaveDropdownOpen(false);
|
||||||
try {
|
try {
|
||||||
|
let targetRuleId = editingRuleId;
|
||||||
if (editingRuleId) {
|
if (editingRuleId) {
|
||||||
await api.updateRule(editingRuleId, {
|
await api.updateRule(editingRuleId, {
|
||||||
name: ruleName.trim(),
|
name: ruleName.trim(),
|
||||||
@@ -348,22 +355,31 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
actions: compiledActions,
|
actions: compiledActions,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
setMessage(`Rule "${ruleName}" updated successfully.`);
|
|
||||||
} else {
|
} else {
|
||||||
await api.createRule({
|
const created = await api.createRule({
|
||||||
name: ruleName.trim(),
|
name: ruleName.trim(),
|
||||||
trigger: ruleTrigger,
|
trigger: ruleTrigger,
|
||||||
match_mode: ruleMatchMode,
|
match_mode: ruleMatchMode,
|
||||||
conditions: compiledConditions,
|
conditions: compiledConditions,
|
||||||
actions: compiledActions,
|
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);
|
setShowRuleModal(false);
|
||||||
loadRules();
|
loadRules();
|
||||||
setTimeout(() => setMessage(null), 3000);
|
setTimeout(() => setMessage(null), 3500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to save rule');
|
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 getNaturalLanguageSummary = (rule: Rule) => {
|
||||||
const triggerText = rule.trigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Ingested';
|
const triggerText = rule.trigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Ingested';
|
||||||
const condTexts: string[] = [];
|
const condTexts: string[] = [];
|
||||||
@@ -509,9 +515,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
if (deleteConfirm.type === 'feed') {
|
if (deleteConfirm.type === 'feed') {
|
||||||
await api.deleteFeed(deleteConfirm.id);
|
await api.deleteFeed(deleteConfirm.id);
|
||||||
setMessage(`Unsubscribed from "${deleteConfirm.title}".`);
|
setMessage(`Unsubscribed from "${deleteConfirm.title}".`);
|
||||||
} else {
|
} else if (deleteConfirm.type === 'category') {
|
||||||
await api.deleteCategory(deleteConfirm.id);
|
await api.deleteCategory(deleteConfirm.id);
|
||||||
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
||||||
|
} else if (deleteConfirm.type === 'rule') {
|
||||||
|
await api.deleteRule(deleteConfirm.id);
|
||||||
|
setMessage(`Deleted rule "${deleteConfirm.title}".`);
|
||||||
|
loadRules();
|
||||||
}
|
}
|
||||||
onRefreshData();
|
onRefreshData();
|
||||||
setTimeout(() => setMessage(null), 3000);
|
setTimeout(() => setMessage(null), 3000);
|
||||||
@@ -909,8 +919,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
background: rule.is_active ? 'rgba(2, 132, 199, 0.1)' : 'var(--bg-tertiary)',
|
background: rule.is_active ? 'var(--bg-tertiary)' : 'var(--bg-app)',
|
||||||
color: rule.is_active ? '#0284c7' : 'var(--text-muted)',
|
border: '1px solid var(--border)',
|
||||||
|
color: rule.is_active ? 'var(--text-main)' : 'var(--text-muted)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -918,7 +929,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Zap size={17} />
|
<Zap size={16} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -936,11 +947,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
|
|
||||||
{/* Natural Language Sentence Preview */}
|
{/* Natural Language Sentence Preview */}
|
||||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-secondary)', marginTop: '0.45rem', lineHeight: 1.45 }}>
|
<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: '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(--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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -964,7 +975,14 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
style={{ width: 28, height: 28, color: 'var(--danger)' }}
|
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"
|
title="Delete Rule"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
@@ -1416,7 +1434,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body" style={{ fontSize: '0.85rem', lineHeight: 1.5 }}>
|
<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>?
|
<strong>"{deleteConfirm.title}"</strong>?
|
||||||
{deleteConfirm.type === 'category' && (
|
{deleteConfirm.type === 'category' && (
|
||||||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)', fontSize: '0.78rem' }}>
|
<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 */}
|
{/* Create / Edit Automation Rule Modal */}
|
||||||
{showRuleModal && (
|
{showRuleModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowRuleModal(false)}>
|
<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
|
||||||
<div className="modal-header" style={{ flexShrink: 0 }}>
|
className="modal-card"
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
style={{
|
||||||
<Sparkles size={18} style={{ color: 'var(--accent)' }} />
|
maxWidth: 660,
|
||||||
<h3>{editingRuleId ? 'Edit Automation Rule' : 'Create Automation Rule'}</h3>
|
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>
|
</div>
|
||||||
<button className="btn-icon" onClick={() => setShowRuleModal(false)}>
|
<button className="btn-icon" onClick={() => setShowRuleModal(false)}>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSaveRule} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
<form
|
||||||
<div className="modal-body" style={{ overflowY: 'auto', flex: 1, paddingRight: '1rem' }}>
|
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 */}
|
{/* Rule Name */}
|
||||||
<div className="form-group" style={{ marginBottom: '1.25rem' }}>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
placeholder="e.g. Auto Scrape Tech Subscriptions"
|
placeholder="e.g. Auto Scrape Tech Subscriptions"
|
||||||
value={ruleName}
|
value={ruleName}
|
||||||
onChange={(e) => setRuleName(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* LEVEL 1: TRIGGER SELECTION */}
|
{/* 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' }}>
|
<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
|
||||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>When This Trigger Occurs</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>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||||
@@ -1485,8 +1552,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
padding: '0.65rem 0.85rem',
|
padding: '0.65rem 0.85rem',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: `1.5px solid ${ruleTrigger === 'on_post_created' ? '#0284c7' : 'var(--border)'}`,
|
border: `1.5px solid ${ruleTrigger === 'on_post_created' ? 'var(--border-dark)' : 'var(--border)'}`,
|
||||||
background: ruleTrigger === 'on_post_created' ? 'rgba(2, 132, 199, 0.05)' : 'var(--bg-app)',
|
background: ruleTrigger === 'on_post_created' ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1495,9 +1562,12 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
name="rule_trigger"
|
name="rule_trigger"
|
||||||
checked={ruleTrigger === 'on_post_created'}
|
checked={ruleTrigger === 'on_post_created'}
|
||||||
onChange={() => setRuleTrigger('on_post_created')}
|
onChange={() => setRuleTrigger('on_post_created')}
|
||||||
|
style={{ accentColor: '#a1a1aa' }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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 style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Trigger on new RSS arrival</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -1509,8 +1579,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
padding: '0.65rem 0.85rem',
|
padding: '0.65rem 0.85rem',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
border: `1.5px solid ${ruleTrigger === 'on_variant_updated' ? '#0284c7' : 'var(--border)'}`,
|
border: `1.5px solid ${ruleTrigger === 'on_variant_updated' ? 'var(--border-dark)' : 'var(--border)'}`,
|
||||||
background: ruleTrigger === 'on_variant_updated' ? 'rgba(2, 132, 199, 0.05)' : 'var(--bg-app)',
|
background: ruleTrigger === 'on_variant_updated' ? 'var(--bg-tertiary)' : 'transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1519,18 +1589,34 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
name="rule_trigger"
|
name="rule_trigger"
|
||||||
checked={ruleTrigger === 'on_variant_updated'}
|
checked={ruleTrigger === 'on_variant_updated'}
|
||||||
onChange={() => setRuleTrigger('on_variant_updated')}
|
onChange={() => setRuleTrigger('on_variant_updated')}
|
||||||
|
style={{ accentColor: '#a1a1aa' }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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 style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Trigger on enrichment update</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ruleTrigger === 'on_variant_updated' && (
|
{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>
|
<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
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={variantSubEvent}
|
value={variantSubEvent}
|
||||||
@@ -1543,7 +1629,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -1557,11 +1645,33 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* LEVEL 2: CONDITIONS & FILTER SCOPE */}
|
{/* 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', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<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
|
||||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>Match Conditions (Filter Scope)</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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.75rem' }}>
|
<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' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
{builderConditions.map((cond, idx) => (
|
{builderConditions.map((cond, idx) => (
|
||||||
<div key={cond.id} style={{ background: 'var(--bg-app)', border: '1px solid var(--border)', borderRadius: 6, padding: '0.75rem' }}>
|
<div
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr auto', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
|
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 */}
|
{/* Field Selector */}
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
@@ -1691,16 +1817,49 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
|
|
||||||
{/* Contextual Value Input */}
|
{/* Contextual Value Input */}
|
||||||
{cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in') ? (
|
{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
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem', gap: '0.5rem' }}>
|
style={{
|
||||||
<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 }}>
|
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)' }} />
|
<Search size={12} style={{ color: 'var(--text-muted)' }} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter feeds..."
|
placeholder="Filter feeds..."
|
||||||
value={feedPickerSearch}
|
value={feedPickerSearch}
|
||||||
onChange={(e) => setFeedPickerSearch(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -1746,11 +1905,19 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
{cond.selectedFeedIds.length} of {feeds.length} feeds selected
|
||||||
</div>
|
</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
|
{feeds
|
||||||
.filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase()))
|
.filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase()))
|
||||||
.map((feed) => {
|
.map((feed) => {
|
||||||
@@ -1764,7 +1931,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
gap: '0.4rem',
|
gap: '0.4rem',
|
||||||
padding: '0.3rem 0.45rem',
|
padding: '0.3rem 0.45rem',
|
||||||
borderRadius: 4,
|
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',
|
fontSize: '0.75rem',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -1781,9 +1949,17 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
updated[idx] = { ...cond, selectedFeedIds: next };
|
updated[idx] = { ...cond, selectedFeedIds: next };
|
||||||
setBuilderConditions(updated);
|
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}
|
{feed.title}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -1825,9 +2001,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
setBuilderConditions(updated);
|
setBuilderConditions(updated);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${isChecked ? '#0284c7' : 'var(--border)'}`,
|
border: `1px solid ${isChecked ? 'var(--border-dark)' : 'var(--border)'}`,
|
||||||
background: isChecked ? 'rgba(2, 132, 199, 0.1)' : 'var(--bg-card)',
|
background: isChecked ? 'var(--bg-tertiary)' : 'var(--bg-app)',
|
||||||
color: isChecked ? '#0284c7' : 'var(--text-main)',
|
color: isChecked ? 'var(--text-main)' : 'var(--text-secondary)',
|
||||||
padding: '0.2rem 0.6rem',
|
padding: '0.2rem 0.6rem',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
@@ -1879,7 +2055,11 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
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}
|
value={cond.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updated = [...builderConditions];
|
const updated = [...builderConditions];
|
||||||
@@ -1916,10 +2096,32 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* LEVEL 3: ACTION EXECUTION PIPELINE */}
|
{/* 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' }}>
|
<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
|
||||||
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-main)' }}>Execute Action Pipeline</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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
@@ -1928,10 +2130,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={builderActions.scrapeFullText}
|
checked={builderActions.scrapeFullText}
|
||||||
onChange={(e) => setBuilderActions({ ...builderActions, scrapeFullText: e.target.checked })}
|
onChange={(e) => setBuilderActions({ ...builderActions, scrapeFullText: e.target.checked })}
|
||||||
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
style={{ accentColor: '#a1a1aa', width: 16, height: 16 }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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)' }}>
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||||
Extracts clean full-text markdown and saves to <code>readability_full_text</code> variant.
|
Extracts clean full-text markdown and saves to <code>readability_full_text</code> variant.
|
||||||
</div>
|
</div>
|
||||||
@@ -1943,11 +2145,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={builderActions.markStarred}
|
checked={builderActions.markStarred}
|
||||||
onChange={(e) => setBuilderActions({ ...builderActions, markStarred: e.target.checked })}
|
onChange={(e) => setBuilderActions({ ...builderActions, markStarred: e.target.checked })}
|
||||||
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
style={{ accentColor: '#a1a1aa', width: 16, height: 16 }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<strong>⭐ Mark as Starred</strong>
|
<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 style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||||
|
Automatically star matching posts for priority reading.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -1956,10 +2160,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={builderActions.markRead}
|
checked={builderActions.markRead}
|
||||||
onChange={(e) => setBuilderActions({ ...builderActions, markRead: e.target.checked })}
|
onChange={(e) => setBuilderActions({ ...builderActions, markRead: e.target.checked })}
|
||||||
style={{ accentColor: '#0284c7', width: 16, height: 16 }}
|
style={{ accentColor: '#a1a1aa', width: 16, height: 16 }}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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 style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>Automatically mark as read on ingestion.</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -1969,16 +2173,20 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={builderActions.setVariant}
|
checked={builderActions.setVariant}
|
||||||
onChange={(e) => setBuilderActions({ ...builderActions, setVariant: e.target.checked })}
|
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 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<strong>🏷️ Set Custom Variant / Tag</strong>
|
<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>
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||||
|
Attach extensible variant metadata to the entry.
|
||||||
|
</div>
|
||||||
|
|
||||||
{builderActions.setVariant && (
|
{builderActions.setVariant && (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem', marginTop: '0.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>Variant Key</label>
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Variant Key
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -1988,7 +2196,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label" style={{ fontSize: '0.72rem' }}>Value</label>
|
<label className="form-label" style={{ fontSize: '0.72rem' }}>
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
@@ -1998,7 +2208,9 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={builderActions.contentType}
|
value={builderActions.contentType}
|
||||||
@@ -2017,16 +2229,32 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* NATURAL LANGUAGE PREVIEW SUMMARY CALLOUT */}
|
{/* 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
|
||||||
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: '#0284c7', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.2rem' }}>
|
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
|
Rule Summary Preview
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-main)', lineHeight: 1.45 }}>
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-secondary)', lineHeight: 1.45 }}>
|
||||||
When <strong>{ruleTrigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Arrives'}</strong>{' '}
|
When <strong style={{ color: 'var(--text-main)' }}>{ruleTrigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Arrives'}</strong>{' '}
|
||||||
{builderConditions.length > 0 && (
|
{builderConditions.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span style={{ color: 'var(--text-muted)' }}>where</span>{' '}
|
<span style={{ color: 'var(--text-muted)' }}>where</span>{' '}
|
||||||
<span style={{ color: '#0284c7', fontWeight: 600 }}>
|
<span style={{ color: 'var(--text-main)', fontWeight: 600 }}>
|
||||||
{builderConditions
|
{builderConditions
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
if (c.field === 'feed_id' && (c.operator === 'in' || c.operator === 'not_in')) {
|
if (c.field === 'feed_id' && (c.operator === 'in' || c.operator === 'not_in')) {
|
||||||
@@ -2039,7 +2267,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{' '}➜ Then{' '}
|
{' '}➜ Then{' '}
|
||||||
<span style={{ color: 'var(--success)', fontWeight: 600 }}>
|
<span style={{ color: '#10b981', fontWeight: 600 }}>
|
||||||
{[
|
{[
|
||||||
builderActions.scrapeFullText && 'Scrape Full Text',
|
builderActions.scrapeFullText && 'Scrape Full Text',
|
||||||
builderActions.markStarred && 'Star Post',
|
builderActions.markStarred && 'Star Post',
|
||||||
@@ -2053,13 +2281,136 @@ export const SettingsView: React.FC<SettingsViewProps> = ({
|
|||||||
</div>
|
</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 }}>
|
{/* FOOTER WITH SPLIT BUTTON */}
|
||||||
<button type="button" className="btn-secondary" onClick={() => setShowRuleModal(false)}>
|
<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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-primary">
|
|
||||||
{editingRuleId ? 'Save Rule Changes' : 'Create Automation Rule'}
|
{/* 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>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
@@ -239,6 +239,14 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
||||||
|
|
||||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -427,6 +435,14 @@ export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<h1 className="reader-headline">
|
||||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||||
{selectedEntry.title}
|
{selectedEntry.title}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { api } from '../services/api';
|
import { api } from '../services/api';
|
||||||
import { Category, FeedEntry, FilterState } from '../types';
|
import { Category, FeedEntry, FilterState } from '../types';
|
||||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags } from '../utils';
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
Inbox,
|
Inbox,
|
||||||
@@ -213,6 +213,14 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
<h3 className="xhs-card-title">{entry.title}</h3>
|
||||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
{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 */}
|
{/* Footer */}
|
||||||
<div className="xhs-card-footer">
|
<div className="xhs-card-footer">
|
||||||
<div className="xhs-publisher-badge">
|
<div className="xhs-publisher-badge">
|
||||||
@@ -442,6 +450,14 @@ export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<h1 className="reader-headline">
|
||||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||||
{selectedEntry.title}
|
{selectedEntry.title}
|
||||||
|
|||||||
+19
-2
@@ -266,6 +266,21 @@ html, body, #root {
|
|||||||
border: 1px solid var(--tag-border);
|
border: 1px solid var(--tag-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.variant-tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile Dropdown Menu */
|
/* Mobile Dropdown Menu */
|
||||||
.mobile-menu-dropdown {
|
.mobile-menu-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -682,11 +697,13 @@ html, body, #root {
|
|||||||
|
|
||||||
.reader-source-bar {
|
.reader-source-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem 0.5rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reader-headline {
|
.reader-headline {
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async applyRule(id: string): Promise<{ message: string; matched_count: number; queued_jobs: number }> {
|
||||||
|
return request<{ message: string; matched_count: number; queued_jobs: number }>(`/rules/${id}/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// OPML
|
// OPML
|
||||||
async importOPML(file: File): Promise<{ imported_count: number; total_parsed: number }> {
|
async importOPML(file: File): Promise<{ imported_count: number; total_parsed: number }> {
|
||||||
const token = localStorage.getItem('rsspipe_jwt');
|
const token = localStorage.getItem('rsspipe_jwt');
|
||||||
|
|||||||
@@ -61,3 +61,17 @@ export function estimateReadingTime(content?: string): string {
|
|||||||
const mins = Math.ceil(words / 200);
|
const mins = Math.ceil(words / 200);
|
||||||
return `${mins} min read`;
|
return `${mins} min read`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract tags from an entry's variants map.
|
||||||
|
* Tags are stored as variant key "tags" with comma-separated text_value.
|
||||||
|
*/
|
||||||
|
export function getEntryTags(variants?: Record<string, { text_value?: string }>): string[] {
|
||||||
|
if (!variants) return [];
|
||||||
|
const tagVariant = variants['tags'];
|
||||||
|
if (!tagVariant?.text_value) return [];
|
||||||
|
return tagVariant.text_value
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user