diff --git a/cmd/server/main.go b/cmd/server/main.go index 302f1a1..96856e4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -49,7 +50,7 @@ func main() { // 3. Initialize EventBus, WorkerPool, and Action Handlers bus := events.NewEventBus() - pool := worker.NewPool(database, bus, 5, 100) + pool := worker.NewPool(database, bus, 5, 1000) // Register Core Action Handlers scraper := readability.NewScraper(15 * time.Second) @@ -68,6 +69,26 @@ func main() { if contentType == "" { contentType = "text" } + + // If key == "tags", merge with existing tags so multiple rules append tags + if key == "tags" { + existingVariant, err := database.GetVariant(entry.ID, "tags") + if err == nil && existingVariant != nil && existingVariant.TextValue != "" { + existingTags := strings.Split(existingVariant.TextValue, ",") + newTags := strings.Split(val, ",") + tagSet := make(map[string]bool) + var merged []string + for _, t := range append(existingTags, newTags...) { + trimmed := strings.TrimSpace(t) + if trimmed != "" && !tagSet[strings.ToLower(trimmed)] { + tagSet[strings.ToLower(trimmed)] = true + merged = append(merged, trimmed) + } + } + val = strings.Join(merged, ", ") + } + } + variant := models.EntryVariant{ EntryID: entry.ID, VariantKey: key, diff --git a/internal/rules/evaluator.go b/internal/rules/evaluator.go index dd45739..867f19f 100644 --- a/internal/rules/evaluator.go +++ b/internal/rules/evaluator.go @@ -60,6 +60,9 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface return entry.URL case "content", "raw_content", "rss_content": var parts []string + if entry.Title != "" { + parts = append(parts, entry.Title) + } if entry.Content != "" { parts = append(parts, entry.Content) } diff --git a/internal/worker/pool.go b/internal/worker/pool.go index 64b7502..87970a5 100644 --- a/internal/worker/pool.go +++ b/internal/worker/pool.go @@ -77,10 +77,22 @@ func (p *Pool) Stop() { } func (p *Pool) Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int) { + task := Task{Action: action, Entry: entry, Depth: depth} select { - case p.queue <- Task{Action: action, Entry: entry, Depth: depth}: + case <-ctx.Done(): + return + case p.queue <- task: + return default: - p.log.Warn("Worker pool queue full, task dropped", + } + + // If queue is full, wait up to 5 seconds for a slot before warning + select { + case <-ctx.Done(): + return + case p.queue <- task: + case <-time.After(5 * time.Second): + p.log.Warn("Worker pool queue full, task dropped after timeout", zap.String("action", action.Type), zap.String("entry_id", entry.ID), ) diff --git a/web/src/components/ThreePaneView.tsx b/web/src/components/ThreePaneView.tsx index b347292..3339e85 100644 --- a/web/src/components/ThreePaneView.tsx +++ b/web/src/components/ThreePaneView.tsx @@ -410,45 +410,47 @@ export const ThreePaneView: React.FC = ({ {/* Reading Content */}
-
- - {selectedEntry.feed_title} - - • - - {' '} - {new Date(selectedEntry.published_at).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - • - {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)} - {selectedEntry.author && ( - <> - • - - {selectedEntry.author} - - +
+
+ + {selectedEntry.feed_title} + + • + + + {new Date(selectedEntry.published_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + • + {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)} + {selectedEntry.author && ( + <> + • + + {selectedEntry.author} + + + )} +
+ +

+ + {selectedEntry.title} + +

+ + {getEntryTags(selectedEntry.variants).length > 0 && ( +
+ {getEntryTags(selectedEntry.variants).map((tag) => ( + 🏷️ {tag} + ))} +
)}
- {getEntryTags(selectedEntry.variants).length > 0 && ( -
- {getEntryTags(selectedEntry.variants).map((tag) => ( - 🏷️ {tag} - ))} -
- )} - -

- - {selectedEntry.title} - -

- {/* Audio player if TTS variant is selected */} {selectedVariantKey === 'tts' && (
diff --git a/web/src/components/WaterfallView.tsx b/web/src/components/WaterfallView.tsx index 82676bc..093bef1 100644 --- a/web/src/components/WaterfallView.tsx +++ b/web/src/components/WaterfallView.tsx @@ -427,43 +427,47 @@ export const WaterfallView: React.FC = ({ {/* Reading Body */}
-
- {selectedEntry.feed_title} - • - - {' '} - {new Date(selectedEntry.published_at).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - • - {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)} - {selectedEntry.author && ( - <> - • - - {selectedEntry.author} - - +
+
+ + {selectedEntry.feed_title} + + • + + + {new Date(selectedEntry.published_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + • + {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)} + {selectedEntry.author && ( + <> + • + + {selectedEntry.author} + + + )} +
+ +

+ + {selectedEntry.title} + +

+ + {getEntryTags(selectedEntry.variants).length > 0 && ( +
+ {getEntryTags(selectedEntry.variants).map((tag) => ( + 🏷️ {tag} + ))} +
)}
- {getEntryTags(selectedEntry.variants).length > 0 && ( -
- {getEntryTags(selectedEntry.variants).map((tag) => ( - 🏷️ {tag} - ))} -
- )} - -

- - {selectedEntry.title} - -

- {/* Dynamic Content Variant Rendering */} {selectedVariantKey === 'tts' && hasTTS ? (
diff --git a/web/src/index.css b/web/src/index.css index 3fe4fab..4d40ef0 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -269,16 +269,22 @@ html, body, #root { .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); + gap: 4px; + padding: 2px 8px; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 500; + background: rgba(16, 185, 129, 0.08); color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.2); + border: 1px solid rgba(16, 185, 129, 0.22); white-space: nowrap; - line-height: 1.5; + line-height: 1.45; + transition: all 0.15s ease; +} + +.variant-tag-chip:hover { + background: rgba(16, 185, 129, 0.15); + border-color: rgba(16, 185, 129, 0.35); } /* Mobile Dropdown Menu */ @@ -695,15 +701,57 @@ html, body, #root { padding: 2rem 1.5rem 5rem 1.5rem; } -.reader-source-bar { +.article-header { + margin-bottom: 1.75rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.reader-source-bar, +.article-header-meta { display: flex; flex-wrap: wrap; align-items: center; - gap: 0.4rem 0.5rem; + gap: 0.5rem 0.65rem; font-size: 0.78rem; color: var(--text-muted); margin-bottom: 0.85rem; - line-height: 1.6; + line-height: 1.5; +} + +.article-feed-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 6px; + font-size: 0.74rem; + font-weight: 600; + background: var(--bg-card); + color: var(--text-main); + border: 1px solid var(--border); +} + +.article-meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text-muted); + font-size: 0.75rem; +} + +.article-meta-divider { + color: var(--border-dark, #3f3f46); + font-size: 0.65rem; + opacity: 0.6; +} + +.article-tags-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-top: 0.85rem; } .reader-headline { @@ -712,7 +760,7 @@ html, body, #root { line-height: 1.28; letter-spacing: -0.02em; color: var(--text-main); - margin-bottom: 1.5rem; + margin-bottom: 0.5rem; } .reader-headline a {