Compare commits
3
Commits
dfbf646332
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebcaa5d72a | ||
|
|
271a542196 | ||
|
|
cd98c0120e |
+28
-13
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/services"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -46,24 +47,37 @@ func main() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// 3. Initialize EventBus, WorkerPool, and Action Handlers
|
// 3. Initialize EventBus
|
||||||
bus := events.NewEventBus()
|
bus := events.NewEventBus()
|
||||||
pool := worker.NewPool(database, bus, 5, 1000)
|
|
||||||
|
|
||||||
// Register Core Action Handlers (readability, status, variants, AI enrich & triage)
|
// 4. Initialize Domain Services
|
||||||
actions.RegisterDefaultHandlers(pool, database, log)
|
entryService := services.NewEntryService(database, bus)
|
||||||
pool.Start(ctx)
|
jobService := services.NewJobService(database, bus)
|
||||||
|
settingsService := services.NewSettingsService(database, bus)
|
||||||
|
|
||||||
// 4. Initialize Rules Engine
|
// 5. Start Background Crawler
|
||||||
engine := rules.NewEngine(database, bus, pool)
|
|
||||||
engine.Start(ctx)
|
|
||||||
|
|
||||||
// 5. Start Background Crawler Pool
|
|
||||||
cr := crawler.NewCrawler(database, bus, cfg.CrawlerIntervalMinutes, cfg.CrawlerWorkers)
|
cr := crawler.NewCrawler(database, bus, cfg.CrawlerIntervalMinutes, cfg.CrawlerWorkers)
|
||||||
cr.Start(ctx)
|
cr.Start(ctx)
|
||||||
|
|
||||||
// 6. Mount API Routes
|
feedService := services.NewFeedService(database, entryService, cr)
|
||||||
handler := api.NewHandler(database, authService, cr, pool)
|
|
||||||
|
// 6. Initialize Persistent SQLite Task Queue Engine
|
||||||
|
jobEngine := worker.NewJobEngine(database, bus, entryService, 10)
|
||||||
|
actions.RegisterJobEngineHandlers(jobEngine, database, log)
|
||||||
|
jobEngine.Start(ctx)
|
||||||
|
|
||||||
|
// 7. Legacy WorkerPool (for backward compatibility if needed)
|
||||||
|
pool := worker.NewPool(database, bus, 5, 1000)
|
||||||
|
actions.RegisterDefaultHandlers(pool, database, log)
|
||||||
|
pool.Start(ctx)
|
||||||
|
|
||||||
|
// 8. Initialize Rules Engine (Transition-Aware Evaluator)
|
||||||
|
rulesEngine := rules.NewEngine(database, bus, pool)
|
||||||
|
rulesEngine.SetEnqueuer(jobService)
|
||||||
|
rulesEngine.Start(ctx)
|
||||||
|
|
||||||
|
// 9. Mount API Routes
|
||||||
|
handler := api.NewHandler(database, authService, bus, entryService, feedService, jobService, settingsService, jobEngine)
|
||||||
router := api.NewRouter(handler, authService, "./web/dist")
|
router := api.NewRouter(handler, authService, "./web/dist")
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
@@ -74,7 +88,7 @@ func main() {
|
|||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Graceful shutdown handler
|
// 10. Graceful shutdown handler
|
||||||
go func() {
|
go func() {
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
@@ -82,6 +96,7 @@ func main() {
|
|||||||
|
|
||||||
log.Info("Shutting down RSSPipe gracefully...")
|
log.Info("Shutting down RSSPipe gracefully...")
|
||||||
cancel()
|
cancel()
|
||||||
|
jobEngine.Stop()
|
||||||
pool.Stop()
|
pool.Stop()
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
|||||||
+22
-9
@@ -6,11 +6,13 @@ This document records planned features, UX enhancements, and architectural exten
|
|||||||
|
|
||||||
## 1. Search Box Auto-Complete & Real-Time Suggestions
|
## 1. Search Box Auto-Complete & Real-Time Suggestions
|
||||||
|
|
||||||
- **Goal**: Enable rich auto-complete and search suggestion dropdown as the user types in the global search bar.
|
- **Status**: **Partially Implemented (Search History Table & Dropdown Active)**
|
||||||
- **Scope**:
|
- **Implemented**:
|
||||||
- Auto-suggest matches across article titles, feed names, author tags, and category labels.
|
- SQLite table `search_history (keyword TEXT PRIMARY KEY, last_searched_at DATETIME, search_count INTEGER)`.
|
||||||
- Keyboard navigation within search dropdown (`ArrowDown`, `ArrowUp`, `Enter` to jump directly to article or feed).
|
- Backend endpoints `GET /api/search/history`, `POST /api/search/history`, `DELETE /api/search/history`.
|
||||||
- Search history / recent queries quick access.
|
- Frontend auto-complete dropdown with keyboard navigation (`ArrowDown`, `ArrowUp`, `Enter`), single-item removal, and clear history.
|
||||||
|
- **Future Extensions**:
|
||||||
|
- Live fuzzy matching across live article titles, feeds, authors, and category labels alongside search history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,12 +61,14 @@ This document records planned features, UX enhancements, and architectural exten
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Feed Favicon & Website Icon Scraping / Upload
|
## 6. Feed Favicon & Website Icon Integration (Completed)
|
||||||
|
|
||||||
- **Goal**: Enhance visual recognition by displaying brand icons next to each feed.
|
- **Goal**: Enhance visual recognition by displaying colorful website brand icons next to each feed.
|
||||||
- **Scope**:
|
- **Scope**:
|
||||||
- **Auto-Scraper**: Automatically fetch and cache `favicon.ico`, `<link rel="icon">`, or `<link rel="apple-touch-icon">` from the feed's website root during initial subscription.
|
- **Auto-Resolution**: Automatically generates and stores high-resolution website favicons (`https://www.google.com/s2/favicons?domain=...&sz=64`) from the feed's website root during subscription and crawler discovery.
|
||||||
- **Manual Customization**: Allow uploading or specifying a custom icon URL in `Settings > Subscriptions > Edit Feed`.
|
- **Fallback**: Gracefully falls back to the clean RSS icon if image loading fails or host is unreachable.
|
||||||
|
- **Manual Customization**: Added full manual customization in `Settings > Feeds > Edit Feed` with live preview and "Auto-Detect" one-click button.
|
||||||
|
- **DB Migration & Backfill**: Automatically added `icon_url` column and backfilled all existing feeds.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -142,3 +146,12 @@ This document records planned features, UX enhancements, and architectural exten
|
|||||||
- Template engine with interpolation of article variables (`title`, `feed_title`, `url`, `published_at`, `ai_summary`, `tags`).
|
- Template engine with interpolation of article variables (`title`, `feed_title`, `url`, `published_at`, `ai_summary`, `tags`).
|
||||||
- Retry mechanism with exponential backoff on delivery failure.
|
- Retry mechanism with exponential backoff on delivery failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Infinite Scrolling Pagination for Desktop 3-Pane Middle List
|
||||||
|
|
||||||
|
- **Goal**: Seamlessly load more articles as the user scrolls down the middle pane in Desktop 3-Split view.
|
||||||
|
- **Scope**:
|
||||||
|
- Integrate windowed offset pagination (`limit: 50`, `offset: entries.length`) with bottom scroll threshold detection on `.desktop-articles-scroll`.
|
||||||
|
- Display non-intrusive loading spinner at the bottom of the article list while fetching subsequent pages.
|
||||||
|
- Maintain active selection index and scroll position during page append.
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
# 007 - UI/UX Architecture & Design Specification
|
||||||
|
|
||||||
|
This document details the complete design, layout philosophy, component hierarchy, gesture system, and CSS token architecture for RSSPipe's renewed frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design Philosophy & System Architecture
|
||||||
|
|
||||||
|
RSSPipe follows a dual-mode philosophy: **Power-Desk Reader** for large desktop screens / tablets and **Xiaohongshu-style Visual Discovery Feed** for mobile devices. Rather than forcing a single responsive compromise, the application decouples into two purpose-built consumption systems sharing core backend state, authentication, and settings.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[App Shell / Router] --> B{Platform & Width Detection}
|
||||||
|
B -->|Screen >= 768px & Pointer| C[Desktop 3-Split System]
|
||||||
|
B -->|Screen < 768px or Touch| D[Mobile Waterfall System]
|
||||||
|
B -->|Manual Toggle / Hotkey 'V'| E[View Mode Override]
|
||||||
|
C --> F[Draggable 3-Column Workspace]
|
||||||
|
D --> G[iOS Bottom Nav + Masonry Grid]
|
||||||
|
C --> H[Shared Core Services & State]
|
||||||
|
D --> H
|
||||||
|
H --> I[Settings, Auth, Modals, Audio Engine]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1 Platform & View Mode Resolution
|
||||||
|
|
||||||
|
1. **Initial Resolution**:
|
||||||
|
- Touch Detection: `'ontouchstart' in window || navigator.maxTouchPoints > 0`
|
||||||
|
- Viewport Width: `window.innerWidth < 768`
|
||||||
|
- If either condition is true and no prior manual user preference exists in `localStorage`, default to `waterfall`. Otherwise, default to `split`.
|
||||||
|
2. **Persistence**:
|
||||||
|
- Stored under `localStorage.getItem('rsspipe_view_mode')`.
|
||||||
|
- Desktop header contains an explicit switch button; pressing `V` (when not focused in an input) toggles between `split` and `waterfall`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Design System & CSS Token Architecture
|
||||||
|
|
||||||
|
RSSPipe adopts a high-contrast, clean minimalist Light Mode design language inspired by Apple Human Interface Guidelines and Linear.app.
|
||||||
|
|
||||||
|
### 2.1 CSS Layering & File Structure
|
||||||
|
|
||||||
|
Styles are organized into modular, maintainable stylesheets loaded in dependency order:
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/styles/
|
||||||
|
├── tokens.css # Core variables: colors, typography, elevations, radiuses, z-indices
|
||||||
|
├── base.css # Reset, typography, app container, custom micro-scrollbars
|
||||||
|
├── three-pane.css # 3-split layout, draggable splitter handles, desktop list, power reader
|
||||||
|
├── waterfall.css # Mobile bottom nav, masonry grid, SmartArt cards, swipe sheets
|
||||||
|
├── settings.css # Restrained, harmonized settings panels (AI, Rules, Feeds)
|
||||||
|
└── modals.css # Shared dialogs (Add Feed, OPML Import/Export, Keyboard Shortcuts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Formal Design Tokens (`tokens.css`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Surface & Background */
|
||||||
|
--bg-app: #f8fafc;
|
||||||
|
--bg-surface: #ffffff;
|
||||||
|
--bg-sidebar: #f8fafc;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-card-hover: #f1f5f9;
|
||||||
|
--bg-card-active: #e2e8f0;
|
||||||
|
--bg-subtle: #f1f5f9;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-light: #f1f5f9;
|
||||||
|
--border-subtle: #e2e8f0;
|
||||||
|
--border-strong: #cbd5e1;
|
||||||
|
--border-focus: #0f172a;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #334155;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--text-dim: #94a3b8;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
|
/* Brand Accents */
|
||||||
|
--accent-primary: #0f172a;
|
||||||
|
--accent-hover: #1e293b;
|
||||||
|
--accent-soft: rgba(15, 23, 42, 0.05);
|
||||||
|
--accent-border: #cbd5e1;
|
||||||
|
|
||||||
|
/* Semantic Colors */
|
||||||
|
--star-gold: #d97706;
|
||||||
|
--star-bg: #fef3c7;
|
||||||
|
--success: #15803d;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--badge-ai: #4f46e5;
|
||||||
|
--badge-tts: #0284c7;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-xs: 4px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Elevations */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-docked: 0 -4px 16px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
/* Layout Dimensions */
|
||||||
|
--header-height: 48px;
|
||||||
|
--bottom-nav-height: 56px;
|
||||||
|
--docked-audio-height: 52px;
|
||||||
|
--sidebar-min-width: 200px;
|
||||||
|
--sidebar-default-width: 240px;
|
||||||
|
--list-min-width: 280px;
|
||||||
|
--list-default-width: 380px;
|
||||||
|
--reader-min-width: 480px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Desktop 3-Split System Specification
|
||||||
|
|
||||||
|
The Desktop 3-Split experience is optimized for speed, precision, and power keyboard navigation.
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------------------------------------------------------------------------------------+
|
||||||
|
| [Header]: RSSPipe | Filter / Search | View Toggle (V) | Add Feed | User Profile |
|
||||||
|
+--------------------+-----------------------------+--------------------------------------+
|
||||||
|
| SIDEBAR (Resizable)| ARTICLE LIST (Resizable) | DETAIL READER (Fluid) |
|
||||||
|
| | | |
|
||||||
|
| 📥 Inbox (12) | [🔍 Search / Filter Pills ] | [Action Toolbar: Star, Read, AI, TTS]|
|
||||||
|
| ⭐ Starred (4) | [Compact / Card Mode Toggle]| ==================================== |
|
||||||
|
| 🏷️ Tech & AI (8) | +-------------------------+ | <h1>Article Title</h1> |
|
||||||
|
| - Hacker News (5)| | [Unread Dot] Feed Title | <p>Source: Hacker News • 2h ago</p> |
|
||||||
|
| - Ars Technica(3)| | Post Headline Title | |
|
||||||
|
| ⚙️ Settings | | 2h ago • 3 min read | [AI Summary Box (Collapsible)] |
|
||||||
|
| | +-------------------------+ | |
|
||||||
|
| | | Next Article Row... | [Markdown / Full-Text Content Body] |
|
||||||
|
| <Drag Handle 1> | <Drag Handle 2> | |
|
||||||
|
+--------------------+-----------------------------+--------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 Draggable Resizable Panes
|
||||||
|
|
||||||
|
- **Handle Component**: `ResizableSplitter` listens to `mousedown`, `mousemove`, `mouseup` on window with an invisible overlay prevent-iframe-drag.
|
||||||
|
- **Constraints**:
|
||||||
|
- Pane 1 (Sidebar): `180px` min, `400px` max. Double-click handle collapses/expands sidebar (`[` key shortcut).
|
||||||
|
- Pane 2 (List): `260px` min, `600px` max.
|
||||||
|
- Pane 3 (Reader): Takes remaining fluid width (`flex: 1`, `min-width: 400px`).
|
||||||
|
- **Width Persistence**: Saved in `localStorage` under `rsspipe_pane_widths: { sidebar, list }`.
|
||||||
|
|
||||||
|
### 3.2 List Mode Toggle (Compact vs Rich Cards)
|
||||||
|
|
||||||
|
- **Compact Mode**: 2-line condensed rows with unread dot, source title, post headline, and relative time. Maximizes scan speed (30+ posts visible per screen).
|
||||||
|
- **Rich Card Mode**: Includes 1-line summary snippet, cover thumbnail (if present), tags, and reading time.
|
||||||
|
|
||||||
|
### 3.3 Desktop Power Reader Toolbar
|
||||||
|
|
||||||
|
- **Variant Selector**: Original RSS / Readability Scraped / AI Translated / Plain Text.
|
||||||
|
- **AI Summary Pill**: Collapsible summary panel with bulleted highlights.
|
||||||
|
- **Scrape Full-Text Button**: Triggers Readability fetch with live spinner.
|
||||||
|
- **Audio TTS Player**: Inline audio bar with play/pause and progress scrubber.
|
||||||
|
- **Actions**: Star (`s`), Mark Unread (`m`), Open in Browser (`o`), Copy Link.
|
||||||
|
|
||||||
|
### 3.4 Keyboard Shortcuts (Vim / Superhuman Style)
|
||||||
|
|
||||||
|
Active exclusively in Desktop 3-Split mode:
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `j` / `↓` | Select next article in list |
|
||||||
|
| `k` / `↑` | Select previous article in list |
|
||||||
|
| `m` / `r` | Toggle Read / Unread status |
|
||||||
|
| `s` | Toggle Star / Bookmark |
|
||||||
|
| `f` | Fetch / Scrape full-text content |
|
||||||
|
| `a` | Toggle AI Summary expansion |
|
||||||
|
| `o` / `v` | Open original article in external tab |
|
||||||
|
| `[` | Collapse / Expand Sidebar |
|
||||||
|
| `/` | Focus search input field |
|
||||||
|
| `?` | Show Keyboard shortcuts modal |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Mobile Waterfall System Specification (Xiaohongshu-Style)
|
||||||
|
|
||||||
|
The Mobile Waterfall experience is designed for thumb-driven browsing, rich visual exploration, and native-feeling gesture interactions.
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------+
|
||||||
|
| [Header]: RSSPipe [🔍 Search] |
|
||||||
|
+------------------------------------------+
|
||||||
|
| [Tabs]: All | Tech | News | Science |
|
||||||
|
+------------------------------------------+
|
||||||
|
| WATERFALL MASONRY GRID (2-Column) |
|
||||||
|
| |
|
||||||
|
| +----------------+ +----------------+ |
|
||||||
|
| | [Image / Cover]| | [SmartArt Text]| |
|
||||||
|
| | Headline text | | Headline text | |
|
||||||
|
| | that wraps 2-3 | | in high-style | |
|
||||||
|
| | lines... | | typography | |
|
||||||
|
| | [Favicon] Feed | | [Favicon] Feed | |
|
||||||
|
| +----------------+ +----------------+ |
|
||||||
|
| +----------------+ +----------------+ |
|
||||||
|
| | [SmartArt Text]| | [Image / Cover]| |
|
||||||
|
| | ... | | ... | |
|
||||||
|
| +----------------+ +----------------+ |
|
||||||
|
+------------------------------------------+
|
||||||
|
| [Docked Mini Audio Player] (If Playing) |
|
||||||
|
+------------------------------------------+
|
||||||
|
| [iOS Bottom Nav]: 🏠Home|📡Feeds|🎧Listen|⭐Starred|⚙️Settings |
|
||||||
|
+------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 iOS 5-Tab Bottom Navigation Bar (`XHSBottomNav`)
|
||||||
|
|
||||||
|
Fixed to the bottom viewport (`height: 56px`, `backdrop-filter: blur(20px)`, `border-top: 1px solid var(--border-subtle)`):
|
||||||
|
|
||||||
|
1. **🏠 Home**:
|
||||||
|
- 2-column masonry waterfall of all incoming posts (weighted/curated discovery).
|
||||||
|
- Top header displays minimal RSSPipe branding and a Search button icon (`🔍`).
|
||||||
|
2. **📡 Feeds**:
|
||||||
|
- Clean source directory grouped by Category.
|
||||||
|
- Shows feed favicon, title, unread count badge, and health status.
|
||||||
|
- Tapping a feed navigates to a dedicated single-feed waterfall view with a top "← Back to Feeds" header.
|
||||||
|
3. **🎧 Listen**:
|
||||||
|
- Filtered waterfall containing only posts with playable audio/TTS/podcast variants.
|
||||||
|
- Cards display audio duration badge (`🎧 4 min`) and 1-tap quick play button.
|
||||||
|
4. **⭐ Starred**:
|
||||||
|
- Feeds and folder list containing starred items with star badge counts.
|
||||||
|
- Tapping opens the filtered Starred posts waterfall.
|
||||||
|
5. **⚙️ Settings**:
|
||||||
|
- Mobile-adapted profile, feeds manager, AI rules, and OPML import/export.
|
||||||
|
|
||||||
|
### 4.2 Card Design & SmartArt Typographic Covers
|
||||||
|
|
||||||
|
- **Posts with Images**: Image rendered at natural aspect ratio with subtle rounded corners (`border-radius: 8px`).
|
||||||
|
- **Posts without Images (SmartArt Cover)**:
|
||||||
|
- Generates a styled visual banner using subtle mathematical gradient seeds based on feed name.
|
||||||
|
- Large expressive serif/sans quote typography displaying the first 6-10 words of the title or excerpt.
|
||||||
|
- Eliminates dull empty gray boxes while maintaining fast pure-CSS rendering.
|
||||||
|
- **Card Footer**:
|
||||||
|
- Source favicon / initial avatar.
|
||||||
|
- Clean truncated feed title.
|
||||||
|
- Relative time (`2h ago`) or Star icon.
|
||||||
|
|
||||||
|
### 4.3 Mobile Full-Screen Article Reader (`MobileArticleSheet`)
|
||||||
|
|
||||||
|
- **Open Transition**: Tapping a card smoothly mounts the full-screen reading sheet with iOS slide-up transition (`transform: translateY(0)`).
|
||||||
|
- **Navigation Gestures**:
|
||||||
|
- **Left-Edge Swipe to Dismiss**: User drags from the left edge (`0px - 30px` boundary) towards the right to swipe away the article back to the waterfall grid.
|
||||||
|
- **No horizontal page skipping**: Prevents accidental article skipping while scrolling long texts.
|
||||||
|
- **Vertical Scrolling**: Standard elastic touch scrolling with high-legibility font sizing (17px body, 1.6 line height).
|
||||||
|
- **Mobile Action Pill**: Floating or top-bar minimal actions (Star, Audio Play, Share, Open in Browser). AI summaries and Readability variants are pre-generated by server rules and rendered inline.
|
||||||
|
|
||||||
|
### 4.4 Docked Mobile Audio Player (`DockedAudioPlayer`)
|
||||||
|
|
||||||
|
- **Docked Bar**: When audio/TTS is playing, docks directly above the Bottom Navigation Bar (`bottom: 56px`):
|
||||||
|
- Mini cover art / favicon.
|
||||||
|
- Scrolling title + feed name.
|
||||||
|
- 15s Rewind (`⏪`), Play/Pause (`⏯️`), 15s Fast-forward (`⏩`), Close (`✕`).
|
||||||
|
- 2px slim live progress line at the top edge of the player.
|
||||||
|
- **Expanded Player Modal**: Tapping the mini-player expands into a full modal sheet:
|
||||||
|
- Large cover art / SmartArt illustration.
|
||||||
|
- Full-width scrubber slider with current time and remaining time.
|
||||||
|
- Playback speed switcher (`0.75x`, `1.0x`, `1.25x`, `1.5x`, `2.0x`).
|
||||||
|
- Bulleted AI highlights / transcript view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Harmonized Settings Design System
|
||||||
|
|
||||||
|
Settings panels (especially AI Rules, Prompt Engineering, and Triage) are refactored from disparate dense layouts into the unified minimal design system:
|
||||||
|
|
||||||
|
- **Navigation Sidebar**: Left list on desktop, top tab pill bar on mobile.
|
||||||
|
- **Section Grouping**:
|
||||||
|
- **Feeds & Folders**: Feed subscription list, category organization, OPML export.
|
||||||
|
- **AI & Automation**: Provider configuration (OpenAI, Gemini, Ollama, DeepSeek), automated scraping rules, TTS voice selection.
|
||||||
|
- **Preferences**: Default view mode, reading font size, auto-mark read on scroll.
|
||||||
|
- **Form Controls**: Consistent `40px` input height, subtle border-focus outline, clean toggle switches, minimal action buttons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Directory Structure & Component Map
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/
|
||||||
|
├── App.tsx # Master shell, view mode resolution, global audio state
|
||||||
|
├── types.ts # Shared interfaces and types
|
||||||
|
├── utils.ts # Formatters, text extractors, SmartArt seed generators
|
||||||
|
├── services/
|
||||||
|
│ └── api.ts # REST API client
|
||||||
|
├── styles/
|
||||||
|
│ ├── tokens.css # [NEW] Design variables
|
||||||
|
│ ├── base.css # [NEW] Baseline typography & layout
|
||||||
|
│ ├── three-pane.css # [NEW] Desktop split panes & splitters
|
||||||
|
│ ├── waterfall.css # [NEW] Mobile waterfall & bottom nav
|
||||||
|
│ ├── settings.css # [NEW] Harmonized settings styles
|
||||||
|
│ └── modals.css # [NEW] Modal dialog styles
|
||||||
|
└── components/
|
||||||
|
├── common/
|
||||||
|
│ ├── Header.tsx # Shared top header
|
||||||
|
│ ├── LoginView.tsx # Login & session initiation
|
||||||
|
│ ├── AddFeedModal.tsx # New feed modal dialog
|
||||||
|
│ ├── OPMLModal.tsx # OPML import/export modal
|
||||||
|
│ └── KeyboardShortcutsModal.tsx # Shortcut cheat sheet
|
||||||
|
├── desktop/
|
||||||
|
│ ├── ThreePaneView.tsx # 3-column container with draggable splitters
|
||||||
|
│ ├── ResizableSplitter.tsx # Drag handle component
|
||||||
|
│ ├── DesktopSidebar.tsx # Feeds, categories, and inbox folder tree
|
||||||
|
│ ├── DesktopArticleList.tsx # List column with Compact/Card toggle
|
||||||
|
│ └── DesktopReader.tsx # Desktop reader pane with power toolbar
|
||||||
|
├── mobile/
|
||||||
|
│ ├── WaterfallView.tsx # Mobile waterfall root container
|
||||||
|
│ ├── XHSBottomNav.tsx # iOS-style 5-tab bottom navigation
|
||||||
|
│ ├── MobileArticleSheet.tsx # Slide-in reader with left-edge swipe dismiss
|
||||||
|
│ ├── SmartArtCard.tsx # Typographic cards for image-less posts
|
||||||
|
│ ├── DockedAudioPlayer.tsx # Sticky mini player + expanded player sheet
|
||||||
|
│ ├── FeedsTabView.tsx # Feeds & category directory tab
|
||||||
|
│ └── StarredTabView.tsx # Starred feeds directory tab
|
||||||
|
└── settings/
|
||||||
|
└── SettingsView.tsx # Unified minimal settings view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Implementation Milestones
|
||||||
|
|
||||||
|
1. **Milestone 1: Design Tokens & Modular CSS Foundation**:
|
||||||
|
- Create `tokens.css`, `base.css`, `three-pane.css`, `waterfall.css`, `settings.css`, `modals.css`.
|
||||||
|
- Update `main.tsx` / `index.html` to import the new style system.
|
||||||
|
2. **Milestone 2: Desktop 3-Split System**:
|
||||||
|
- Implement `ResizableSplitter` and update `ThreePaneView`.
|
||||||
|
- Add Compact List vs Rich Card toggle.
|
||||||
|
- Implement Vim keyboard shortcuts (`j`/`k`, `m`, `s`, `[`).
|
||||||
|
3. **Milestone 3: Mobile Waterfall & iOS Bottom Navigation**:
|
||||||
|
- Implement `XHSBottomNav` with the 5 tabs (`Home`, `Feeds`, `Listen`, `Starred`, `Settings`).
|
||||||
|
- Create `SmartArtCard` for image-less posts.
|
||||||
|
- Build `MobileArticleSheet` with left-edge touch swipe-to-dismiss gesture.
|
||||||
|
4. **Milestone 4: Docked Mobile Audio Player**:
|
||||||
|
- Build `DockedAudioPlayer` for TTS/podcast playback with docked mini-bar and expandable sheet.
|
||||||
|
5. **Milestone 5: Harmonize Settings & Shared Modals**:
|
||||||
|
- Refactor `SettingsView` (AI, Rules, Feeds) to use clean design tokens.
|
||||||
|
- End-to-end responsive & touch testing.
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# 008 - Desktop & Mobile View Feedback & Design Specifications
|
||||||
|
|
||||||
|
This document comprehensively records all user feedback, UX directives, and architectural decisions made for both the **Desktop 3-Pane View** and the **Mobile Waterfall View** in RSSPipe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PART A: Desktop 3-Pane View Specifications
|
||||||
|
|
||||||
|
## 1. Design Aesthetics & Visual Identity
|
||||||
|
|
||||||
|
- **Theme Palette**: Metal-inspired, refined monochrome with sleek slate/zinc surfaces (`#09090b`, `#18181b`, `#27272a`), crisp typography (Inter/system), and dedicated functional color accents (Gold for Starred, Emerald for Tags, Blue for Keywords/AI).
|
||||||
|
- **Surface Elevation**: Clean hairline borders (`1px solid var(--border-subtle)`), eliminating heavy shadows or dated blue-gray tints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Top Header Bar
|
||||||
|
|
||||||
|
| Element | Specification & User Directive | Status |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Brand & Icon** | RSSPipe logo and brand name anchored at the leftmost position across all views. | Implemented |
|
||||||
|
| **Search Bar** | Widened search input with auto-completion backed by `search_history` database table (`keyword`, `last_searched_at`, `search_count`). | Implemented |
|
||||||
|
| **Refresh Button** | Smooth keyframe rotation animation with guaranteed visual feedback duration on click/poll. | Implemented |
|
||||||
|
| **Left Sidebar Toggle** | Removed from top header (sidebar remains permanently accessible in 3-pane desktop). | Implemented |
|
||||||
|
| **View Switcher** | Removed "3-connected-button" switcher from top bar; view modes configured via Settings > General. | Implemented |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Left Sidebar (Navigation Pane)
|
||||||
|
|
||||||
|
### Structure & Hierarchy
|
||||||
|
|
||||||
|
1. **Views Section**:
|
||||||
|
- `Inbox` (Unread count badge)
|
||||||
|
- `Starred` (Starred count badge)
|
||||||
|
- `All Articles`
|
||||||
|
- `Shared` (Public share count badge)
|
||||||
|
2. **Feeds Section**:
|
||||||
|
- Category Folders with nested feeds
|
||||||
|
- Uncategorized feeds at root level
|
||||||
|
|
||||||
|
### UI / UX Rules
|
||||||
|
|
||||||
|
- **Category Chevron Alignment**: Fold/unfold toggle icon is placed **immediately after the category title** (before the unread count), ensuring category feeds and uncategorized feeds share the exact same left margin and indentation.
|
||||||
|
- **Unread Badges**: Pinned flush to the far right edge of the item row.
|
||||||
|
- **Feed Spacing**: Tight, efficient vertical spacing (`gap: 1px`, `min-height: 28px`).
|
||||||
|
- **Feed Favicons**:
|
||||||
|
- Automatically resolved from website root (`https://www.google.com/s2/favicons?domain=...&sz=64`) upon subscription.
|
||||||
|
- Rendered with live error fallback to the default RSS icon.
|
||||||
|
- Fully customizable in `Settings > Feeds > Edit Feed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Middle Pane (Article List)
|
||||||
|
|
||||||
|
### Mode 1: Compact List (`compact`)
|
||||||
|
|
||||||
|
- **Row Structure (2-Line)**:
|
||||||
|
- **Line 1**: Article Headline (`entry.title`)
|
||||||
|
- **Line 2**: Published Date (`YYYY-MM-DD`) + right-aligned conditional icons.
|
||||||
|
- **Conditional Status Icons**:
|
||||||
|
- ⭐ **Starred**: Gold star button (rendered only when starred; 0px width when unstarred).
|
||||||
|
- 🔗 **Shared**: `Share2` icon (rendered only when `share_uuid` exists).
|
||||||
|
|
||||||
|
### Mode 2: Expanded / Non-Compact List (`rich`)
|
||||||
|
|
||||||
|
- **Strict Rule**: **No thumbnail images** in entry cards.
|
||||||
|
- **Row Structure**:
|
||||||
|
- **Line 1**: `:title` (Article headline)
|
||||||
|
- **Line 2**: `:description` (Clean text snippet, stripped HTML)
|
||||||
|
- **Line 3**: `:date` (`YYYY-MM-DD` left-aligned) + right-aligned status indicators (`starred`, `shared`, `ai-summarized`).
|
||||||
|
- **Line 4**: `:keywords` and `:tags` in distinct colors (Blue keyword chips, Emerald `#tag` chips).
|
||||||
|
|
||||||
|
### Scope & Filtering
|
||||||
|
|
||||||
|
- **Dynamic Header Title**: Updates to show active feed name, category title, or search query.
|
||||||
|
- **Scope Toggle**: `[ Unread | All ]` pill switcher when browsing a specific feed/category.
|
||||||
|
- **Query Reset**: Automatically resets search queries upon selecting a feed/category to prevent "no articles found" confusion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Right Pane (Reading Canvas)
|
||||||
|
|
||||||
|
### Top Action Toolbar
|
||||||
|
|
||||||
|
- **Left Group**:
|
||||||
|
- `Toggle Unread`
|
||||||
|
- `Toggle Star`
|
||||||
|
- `Toggle Shared` (auto-copies link to clipboard)
|
||||||
|
- `Copy Link`
|
||||||
|
- `Open Original Site` (new tab)
|
||||||
|
- **Right Group**:
|
||||||
|
- **Split Action Button**:
|
||||||
|
- Default click: `Get Full Text` (scrapes via Readability and switches view to cleaned full text; toggles between `Original Feed` and `Full Text`).
|
||||||
|
- Dropdown menu: `Generate AI Summary` and `Generate Audio`.
|
||||||
|
- *No extraneous public share link buttons in top menu.*
|
||||||
|
|
||||||
|
### Article Content Layout & Ordering
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Feed Subtitle + Favicon (feed_title)
|
||||||
|
2. Post Title (h1)
|
||||||
|
3. Author (if exists)
|
||||||
|
4. Updated Date (YYYY-MM-DD HH:mm) • Reading Time
|
||||||
|
5. Keywords Chips (if exists)
|
||||||
|
6. Tags Chips (if exists)
|
||||||
|
──────────────────────────────────────────────────── (Divider)
|
||||||
|
7. AI Summary Card (in-page error notice if failed)
|
||||||
|
8. Audio Narration Player (if generated)
|
||||||
|
9. Article Content Body (HTML / Full Text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout & Scrollbar
|
||||||
|
|
||||||
|
- **Far-Right Scrollbar**: The scroll container spans the full pane width so the native scrollbar sits against the far right edge of the screen, while article content remains centered at a readable max-width (`760px`).
|
||||||
|
- **AI Summary Error Notice**: In-page error banner within the summary container instead of modal `alert()` popups.
|
||||||
|
|
||||||
|
### Automatic Read Triggers
|
||||||
|
|
||||||
|
When the reading preference is enabled (`auto_read`):
|
||||||
|
|
||||||
|
1. **Short Articles (Content ≤ 1 Page)**: Automatically marked as read after staying on the article for **3 seconds**.
|
||||||
|
2. **Content Click**: Clicking anywhere inside the reader content canvas (excluding the toolbar) immediately marks as read.
|
||||||
|
3. **Long Articles (Content > 1 Page)**: Scrolling down the right reading pane immediately marks as read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PART B: Mobile Waterfall View & Reader Specifications
|
||||||
|
|
||||||
|
## 6. General Layout & Safe Area
|
||||||
|
|
||||||
|
- **iOS Bottom Bar Safe Area**:
|
||||||
|
- The 5-button bottom navigation bar (`.ios-bottom-nav`) is elevated with dedicated safe area breathing room (`height: 72px`, `padding-bottom: calc(16px + env(safe-area-inset-bottom))`) above the iOS virtual home bar.
|
||||||
|
- **Top Header Bar Removal**:
|
||||||
|
- Removed top branding header, search icon, and view switcher to maximize vertical reading canvas on mobile screens.
|
||||||
|
- **Interactive Left-Edge Gesture**:
|
||||||
|
- Swiping from within 45px of the left screen edge provides interactive horizontal dragging (`translateX`) with smooth slide-out dismissal animation instead of an abrupt jump.
|
||||||
|
- **Scroll Position Retention**:
|
||||||
|
- Returning from a post waterfall feed back to the previous list (Subscriptions or Starred) preserves the exact list scroll location.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Home Tab (Discovery Waterfall)
|
||||||
|
|
||||||
|
- **Categories Bar at Top**:
|
||||||
|
- The horizontal scrollable category pill bar sits directly at the top of the Home tab, with the `"All"` tab active by default.
|
||||||
|
- **Floating Integrated Search Bar**:
|
||||||
|
- Placed on the same top row as categories on the far right.
|
||||||
|
- Tapping the search icon smoothly expands the search bar from right to left with an integrated clear button and auto-focus.
|
||||||
|
- **Pull-to-Refresh & Discovery Reshuffle**:
|
||||||
|
- Dragging down at the top of the waterfall feed (~1/3 screen threshold) reveals a pull indicator (*"Pull down to refresh"* ➔ *"Release to reload & shuffle"*).
|
||||||
|
- Releasing triggers a refresh and **randomly reshuffles post cards** for an engaging discovery experience.
|
||||||
|
- **SmartArt Post Cards (No-Image Fallback)**:
|
||||||
|
- For articles without images, SmartArt cards render clean post description text snippets instead of raw article URLs or empty containers.
|
||||||
|
- **Automatic Infinite Scroll**:
|
||||||
|
- Automatically loads more articles when scrolling to the bottom of the feed.
|
||||||
|
- **Double-Tap Home Tab**:
|
||||||
|
- Double-tapping the 🏠 Home icon on the bottom navigation bar smoothly scrolls the waterfall feed back to the top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Mobile Full-Screen Article Reader (`MobileArticleSheet`)
|
||||||
|
|
||||||
|
### Top Navigation Row
|
||||||
|
|
||||||
|
- **Single-Row Layout (`.mobile-sheet-top-nav`)**:
|
||||||
|
- `[ < Return Button ] [ Feed Favicon ] [ Feed Title ]`
|
||||||
|
- Eliminates clutter and keeps the feed identity anchored at the top.
|
||||||
|
|
||||||
|
### Article Content Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Post Title (h1)
|
||||||
|
2. Author · Published Date · Estimated Reading Time
|
||||||
|
────────────────────────────────────────────────────
|
||||||
|
3. AI Summary Box (Purple theme, copy button)
|
||||||
|
4. Article Content Body (HTML / Full Text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Horizontal Page Scrolling
|
||||||
|
|
||||||
|
- Universal horizontal overflow protection (`overflow-x: hidden; width: 100%; max-width: 100vw;`).
|
||||||
|
- Forced word wrapping (`word-break: break-word; overflow-wrap: anywhere;`) on all article canvas text, links, and code blocks to prevent unwrapped URLs from causing horizontal viewport shifts.
|
||||||
|
|
||||||
|
### Docked Bottom Action Toolbar (`.mobile-sheet-bottom-bar`)
|
||||||
|
|
||||||
|
- **Bottom-Left Corner (Action Icons)**:
|
||||||
|
- ⭐ **Star**: Toggles between empty star and filled **Yellow/Gold** (`#d97706`).
|
||||||
|
- 🔗 **Share**: Toggles public share link, copies URL to clipboard with confirmation banner, and highlights in **Electric Blue** (`#2563eb`).
|
||||||
|
- 🎧 **Audio / TTS**: Shows in **Emerald Green** (`#059669`) with live play/pause control when narration is available.
|
||||||
|
- ↗️ **Original Link**: Opens original web link in a new browser tab.
|
||||||
|
- **Bottom-Right Corner (Popup Split Action)**:
|
||||||
|
- Split button (`Get Full Text` / `Original`) with an upward chevron that opens a **Dropup Popup Menu**:
|
||||||
|
- 🔮 **Generate AI Summary**: **Purple** (`#7c3aed`).
|
||||||
|
- 🎙️ **Generate Audio (TTS)**: **Emerald Green** (`#059669`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Harmonized Color Identity System
|
||||||
|
|
||||||
|
| Feature | Primary Hex Code | CSS Variable / Token | State Appearance |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Star / Favorited** | `#d97706` (Amber/Gold) | `--badge-star` | Solid filled star |
|
||||||
|
| **Public Share** | `#2563eb` (Electric Blue) | `--badge-shared` | Solid blue icon + soft tint background |
|
||||||
|
| **AI Summary** | `#7c3aed` (Royal Purple) | `--badge-ai` | Purple icon, border & chip |
|
||||||
|
| **Audio / TTS** | `#059669` (Emerald Green) | `--badge-tts` | Emerald icon + soft green pill |
|
||||||
|
| **Keywords** | `#0284c7` (Sky Blue) | `--badge-keyword` | Sky blue badge chip |
|
||||||
|
| **Tags** | `#10b981` (Emerald Green) | `--badge-tag` | Emerald `#tag` chip |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Cross-Device Settings & Real-Time Sync
|
||||||
|
|
||||||
|
- **Server-Side Persistence**:
|
||||||
|
- All user reading preferences (`auto_read`, `view_preference`, theme) are stored in the SQLite `users.preferences` column via `GET/PUT /api/user/preferences`.
|
||||||
|
- **Multi-Session Synchronization**:
|
||||||
|
- Preferences automatically sync with `localStorage` on session start.
|
||||||
|
- Added window `focus` and tab `visibilitychange` listeners in `App.tsx` and `SettingsView.tsx`, ensuring setting changes made on mobile immediately reflect on desktop when switching windows.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# RSSPipe Worklog & Architectural Refactoring (`009_worklog.md`)
|
||||||
|
|
||||||
|
This document records the comprehensive architectural overhaul, bug fixes, and engineering enhancements implemented across the RSSPipe frontend and backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview & Objectives
|
||||||
|
|
||||||
|
The primary goal of this milestone was to transform RSSPipe into a resilient, reactive, and easily extensible event-driven platform capable of executing asynchronous pipelines (e.g. readability extraction, AI summarization, tagging, text-to-speech) without concurrency bottlenecks or UI state drift.
|
||||||
|
|
||||||
|
### Core Problems Addressed
|
||||||
|
|
||||||
|
1. **Monolithic API Structure**: A single 1,140-line `handlers.go` held all business logic, making feature expansion error-prone.
|
||||||
|
2. **Missing State Transitions**: Internal background updates (e.g., crawler writes, variant additions) lacked declarative event propagation.
|
||||||
|
3. **Task Concurrency & Recovery**: Long-running or rate-limited tasks needed persistent queuing, exponential backoff retries, and crash recovery.
|
||||||
|
4. **SQLite Concurrency & Lock Collisions**: Concurrent worker writes threw `SQLITE_BUSY (5)` due to pool-level PRAGMA isolation.
|
||||||
|
5. **Cross-Tab State Synchronization**: Updating read/star status in one session did not reflect in other sessions or tabs.
|
||||||
|
6. **Mobile UX on iOS**: Fixed bottom action bars overlapping with the iOS virtual home indicator bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architectural Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────────────────────────┐
|
||||||
|
│ HTTP API / Worker │
|
||||||
|
└─────────────────────────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ EntryService.PatchEntry() │
|
||||||
|
│ (Atomic DB Update + EntryDiff) │
|
||||||
|
└────────────────┬─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ EventBus.Publish(Transition) │
|
||||||
|
└────────┬─────────────────┬───────┘
|
||||||
|
│ │
|
||||||
|
┌────────────────────┘ └────────────────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
|
||||||
|
│ Server-Sent Events (SSE) │ │ RulesEngine │
|
||||||
|
│ GET /api/events/stream │ │ (Evaluate Old vs New State) │
|
||||||
|
└──────────────────┬───────────────────┘ └──────────────────┬───────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
|
||||||
|
│ Client Tabs (Real-Time Reactive UI) │ │ JobEngine.Enqueue(Job) │
|
||||||
|
└──────────────────────────────────────┘ └──────────────────┬───────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ Persistent SQLite `jobs` Table │
|
||||||
|
│ (Workers, Retries, Idempotency) │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Atomic State Transitions & Event Bus
|
||||||
|
|
||||||
|
- Implemented `PatchEntry(ctx, id, patch)` in `internal/services/entry_service.go`.
|
||||||
|
- Computes atomic `EntryDiff` comparing pre-update and post-update state:
|
||||||
|
- Boolean flag changes (`is_read`, `is_starred`).
|
||||||
|
- Added or updated variants (e.g. `readability_full_text`, `ai_summary`, `tts_audio`).
|
||||||
|
- Publishes `EventEntryTransition(OldState, NewState, Diff)` onto `internal/events/bus.go`.
|
||||||
|
- Enables declarative rules evaluation on every state change without tight coupling.
|
||||||
|
|
||||||
|
### 2.2 Persistent SQLite Job Queue (`JobEngine`)
|
||||||
|
|
||||||
|
- Created `jobs` table in SQLite schema with lifecycle states (`pending`, `running`, `completed`, `failed`, `dead`).
|
||||||
|
- Added worker pool in `internal/worker/engine.go` with:
|
||||||
|
- Concurrency limits (default 5 concurrent workers).
|
||||||
|
- Exponential backoff retries (`run_at` scheduling).
|
||||||
|
- Idempotency key deduplication (prevents duplicate execution of identical tasks).
|
||||||
|
- Crash recovery on engine startup (resets zombie `running` jobs back to `pending`).
|
||||||
|
- Registered handlers for:
|
||||||
|
- `readability_scrape` (Readability full-text extraction).
|
||||||
|
- `ai_summary` / `ai_enrich` (LLM summarization and variant generation).
|
||||||
|
- `mark_read` / `mark_starred` (Status modifications).
|
||||||
|
- `set_variant` (Structured tag and variant persistence).
|
||||||
|
|
||||||
|
### 2.3 Modular Backend HTTP Handlers
|
||||||
|
|
||||||
|
Decomposed the monolithic `handlers.go` into domain-specific files under `internal/api/`:
|
||||||
|
|
||||||
|
- `auth_handler.go`: Authentication, session verification, login, and logout.
|
||||||
|
- `feeds_handler.go`: Feed CRUD, OPML import/export, manual refresh, and favicon resolution.
|
||||||
|
- `entries_handler.go`: Entry queries, filtering, atomic patch, scrape, and AI actions.
|
||||||
|
- `jobs_handler.go`: Job queue querying, execution status, and asynchronous dispatch.
|
||||||
|
- `settings_handler.go`: User preferences and AI provider configuration.
|
||||||
|
- `rules_handler.go`: Automation rule CRUD and test execution.
|
||||||
|
- `sse_handler.go`: Server-Sent Events real-time event streaming.
|
||||||
|
|
||||||
|
### 2.4 Real-Time Reactive Synchronization (SSE)
|
||||||
|
|
||||||
|
- Added `GET /api/events/stream` endpoint authenticated via Bearer token / session cookie.
|
||||||
|
- Connected event stream in `web/src/App.tsx` via `api.connectEventStream()`.
|
||||||
|
- Automatically syncs read status, star status, and variant additions in real-time across multiple open browser tabs or devices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Critical Bug Fixes
|
||||||
|
|
||||||
|
### 3.1 SQLite `SQLITE_BUSY (5)` Database Lock Fix
|
||||||
|
|
||||||
|
- **Root Cause**: In Go's `database/sql`, executing `PRAGMA busy_timeout = 5000;` on startup only applied to a single pooled connection. Subsequent pool connections defaulted to `0ms` timeout, immediately throwing `SQLITE_BUSY` when concurrent writes occurred.
|
||||||
|
- **Solution**: Configured PRAGMAs directly in the SQLite DSN connection string in `internal/db/db.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
dsn := fmt.Sprintf("%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=cache_size(-20000)", dbPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every connection checked out of the pool now inherits a 10-second busy timeout and WAL mode.
|
||||||
|
|
||||||
|
### 3.2 AI Settings & Prompt Template Unification
|
||||||
|
|
||||||
|
- **Root Cause**: `SettingsService` saved AI configuration as a unified JSON object under key `"ai_settings"`, but `LoadConfig` in the AI actions was reading non-existent legacy individual keys (`ai_enabled`, `ai_api_key`). This caused prompt templates to be ignored and disabled states to be bypassed.
|
||||||
|
- **Solution**: Refactored `LoadConfig` in `internal/actions/ai/enrich.go` to unmarshal `"ai_settings"` JSON. Enforced strict enabled checks and wired custom prompt templates (e.g. prompt `"a": "aaa"`).
|
||||||
|
|
||||||
|
### 3.3 Undo Share State Reset
|
||||||
|
|
||||||
|
- **Root Cause**: `models.FeedEntry` had `omitempty` on `ShareUUID *string`, which omitted `share_uuid` from JSON responses when set to `nil` rather than outputting `null`.
|
||||||
|
- **Solution**: Removed `omitempty` from `ShareUUID` in `internal/models/models.go` and updated `DesktopReader.tsx` and `MobileArticleSheet.tsx` to explicitly reset `share_uuid: res.share_uuid ?? null`.
|
||||||
|
|
||||||
|
### 3.4 Middle Bar Unread / All Count Discrepancy
|
||||||
|
|
||||||
|
- **Root Cause**: The middle article list header count displayed in-memory array lengths (`entries.length`) rather than true database statistics from `stats` and feed/category metadata.
|
||||||
|
- **Solution**: Updated `DesktopArticleList.tsx` to compute true database counts from `stats` and feed totals, and reverted switch buttons to clean `Unread` and `All` labels.
|
||||||
|
|
||||||
|
### 3.5 Mobile iOS Safe Area Insets & Home Indicator
|
||||||
|
|
||||||
|
- **Root Cause**: `viewport-fit=cover` was missing in `web/index.html`, causing iOS Safari to evaluate `env(safe-area-inset-bottom)` to `0px`.
|
||||||
|
- **Solution**: Added `viewport-fit=cover` and calibrated bottom navigation height (`50px + env(safe-area-inset-bottom, 0px)`) and article bottom toolbar height (`48px + env(safe-area-inset-bottom, 0px)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Work Items & Backlog for Future Milestones
|
||||||
|
|
||||||
|
The following items have been identified for future implementation:
|
||||||
|
|
||||||
|
1. **SSE Auto-Reconnect & Connection State Recovery**:
|
||||||
|
- Exponential backoff with jitter on EventSource disconnection in `web/src/api.ts`.
|
||||||
|
- Snapshot reconciliation (`/api/stats` and `/api/entries?since=...`) upon reconnect.
|
||||||
|
|
||||||
|
2. **Background Job Queue & Dead-Letter Management in Admin Settings**:
|
||||||
|
- Dedicated "Job Queue" tab in `Settings > Account & System`.
|
||||||
|
- Live table of tasks (`pending`, `running`, `completed`, `failed`, `dead`) with error logs and manual retry/cancel actions.
|
||||||
|
|
||||||
|
3. **Reader UI Prompt Template Selector**:
|
||||||
|
- Dropdown selector next to "Generate AI Summary" button in Desktop and Mobile reader bars to choose between configured prompt templates on the fly.
|
||||||
|
|
||||||
|
4. **Local Ollama Model Autodiscovery & Health Check**:
|
||||||
|
- "Discover Models" button in `Settings > AI & Integrations` querying `http://localhost:11434/api/tags` to populate the model list automatically.
|
||||||
|
|
||||||
|
5. **Desktop 3-Pane Middle List Infinite Scrolling**:
|
||||||
|
- Windowed offset pagination (`limit: 50`, `offset: entries.length`) with scroll threshold trigger on `.desktop-articles-scroll`.
|
||||||
@@ -2,6 +2,7 @@ package ai
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||||
@@ -12,7 +13,35 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// LoadConfig fetches current AI provider configuration from system settings.
|
// LoadConfig fetches current AI provider configuration from system settings.
|
||||||
func LoadConfig(database *db.DB) ai.Config {
|
func LoadConfig(database *db.DB) (ai.Config, []models.PromptTemplate) {
|
||||||
|
raw, err := database.GetSetting("ai_settings")
|
||||||
|
if err == nil && raw != "" {
|
||||||
|
var s models.AISettings
|
||||||
|
if err := json.Unmarshal([]byte(raw), &s); err == nil {
|
||||||
|
provider := s.Provider
|
||||||
|
if provider == "" {
|
||||||
|
provider = "openai"
|
||||||
|
}
|
||||||
|
baseURL := s.BaseURL
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
model := s.Model
|
||||||
|
if model == "" {
|
||||||
|
model = "gpt-4o-mini"
|
||||||
|
}
|
||||||
|
return ai.Config{
|
||||||
|
Enabled: s.Enabled,
|
||||||
|
Provider: provider,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
APIKey: s.APIKey,
|
||||||
|
Model: model,
|
||||||
|
Temperature: s.Temperature,
|
||||||
|
MaxTokens: s.MaxTokens,
|
||||||
|
}, s.Prompts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
settings, _ := database.GetSettingsMap()
|
settings, _ := database.GetSettingsMap()
|
||||||
enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
|
enabled := settings["ai_enabled"] == "true" || settings["ai_enabled"] == "1"
|
||||||
provider := settings["ai_provider"]
|
provider := settings["ai_provider"]
|
||||||
@@ -33,13 +62,19 @@ func LoadConfig(database *db.DB) ai.Config {
|
|||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
APIKey: settings["ai_api_key"],
|
APIKey: settings["ai_api_key"],
|
||||||
Model: model,
|
Model: model,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEnrichmentHandler creates a worker action handler for AI variant enrichment.
|
// NewEnrichmentHandler creates a worker action handler for AI variant enrichment.
|
||||||
func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) worker.ActionHandler {
|
func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) worker.ActionHandler {
|
||||||
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
|
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
|
||||||
cfg := LoadConfig(database)
|
cfg, prompts := LoadConfig(database)
|
||||||
|
if !cfg.Enabled {
|
||||||
|
log.Warn("AI action skipped: AI features are disabled in Settings",
|
||||||
|
zap.String("entry_id", entry.ID),
|
||||||
|
)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
if cfg.APIKey == "" && cfg.Provider != "ollama" {
|
if cfg.APIKey == "" && cfg.Provider != "ollama" {
|
||||||
log.Warn("AI action skipped: API key is not configured in Settings > AI & Integrations",
|
log.Warn("AI action skipped: API key is not configured in Settings > AI & Integrations",
|
||||||
zap.String("entry_id", entry.ID),
|
zap.String("entry_id", entry.ID),
|
||||||
@@ -52,6 +87,17 @@ func NewEnrichmentHandler(database *db.DB, client *ai.Client, log *zap.Logger) w
|
|||||||
if prompt == "" {
|
if prompt == "" {
|
||||||
prompt, _ = params["custom_prompt"].(string)
|
prompt, _ = params["custom_prompt"].(string)
|
||||||
}
|
}
|
||||||
|
if prompt == "" {
|
||||||
|
for _, p := range prompts {
|
||||||
|
if p.IsDefault {
|
||||||
|
prompt = p.Prompt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prompt == "" && len(prompts) > 0 {
|
||||||
|
prompt = prompts[0].Prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
vKey, _ := params["variant_key"].(string)
|
vKey, _ := params["variant_key"].(string)
|
||||||
if vKey == "" {
|
if vKey == "" {
|
||||||
vKey = "ai_summary"
|
vKey = "ai_summary"
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import (
|
|||||||
// NewTriageHandler creates a worker action handler for AI autonomous triage decisions.
|
// NewTriageHandler creates a worker action handler for AI autonomous triage decisions.
|
||||||
func NewTriageHandler(database *db.DB, client *ai.Client, pool *worker.Pool, log *zap.Logger) worker.ActionHandler {
|
func NewTriageHandler(database *db.DB, client *ai.Client, pool *worker.Pool, log *zap.Logger) worker.ActionHandler {
|
||||||
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
|
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
|
||||||
cfg := LoadConfig(database)
|
cfg, _ := LoadConfig(database)
|
||||||
|
if !cfg.Enabled {
|
||||||
|
log.Warn("AI triage skipped: AI features are disabled in Settings", zap.String("entry_id", entry.ID))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
if cfg.APIKey == "" && cfg.Provider != "ollama" {
|
if cfg.APIKey == "" && cfg.Provider != "ollama" {
|
||||||
log.Warn("AI triage action skipped: API key is not configured in Settings > AI & Integrations",
|
log.Warn("AI triage action skipped: API key is not configured in Settings > AI & Integrations",
|
||||||
zap.String("entry_id", entry.ID),
|
zap.String("entry_id", entry.ID),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai"
|
||||||
@@ -9,11 +12,12 @@ import (
|
|||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/variants"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/variants"
|
||||||
aiClientPkg "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
aiClientPkg "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterDefaultHandlers registers all core action handlers with the worker pool.
|
// RegisterDefaultHandlers registers legacy action handlers with worker.Pool
|
||||||
func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger) {
|
func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger) {
|
||||||
// 1. Readability Full-Text Scraper
|
// 1. Readability Full-Text Scraper
|
||||||
scraper := readability.NewScraper(15 * time.Second)
|
scraper := readability.NewScraper(15 * time.Second)
|
||||||
@@ -34,3 +38,189 @@ func RegisterDefaultHandlers(pool *worker.Pool, database *db.DB, log *zap.Logger
|
|||||||
|
|
||||||
pool.RegisterHandler("ai_agent_action", ai.NewTriageHandler(database, aiClient, pool, log))
|
pool.RegisterHandler("ai_agent_action", ai.NewTriageHandler(database, aiClient, pool, log))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterJobEngineHandlers registers async task handlers with the persistent JobEngine
|
||||||
|
func RegisterJobEngineHandlers(engine *worker.JobEngine, database *db.DB, log *zap.Logger) {
|
||||||
|
scraper := readability.NewScraper(20 * time.Second)
|
||||||
|
aiClient := aiClientPkg.NewClient(60 * time.Second)
|
||||||
|
|
||||||
|
// 1. Readability Scrape Handler
|
||||||
|
engine.RegisterHandler("readability_scrape", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
entryID, _ := job.Payload["entry_id"].(string)
|
||||||
|
if entryID == "" {
|
||||||
|
return nil, nil, fmt.Errorf("missing entry_id in job payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := database.GetEntry(entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("entry not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := scraper.Handler()(ctx, *entry, job.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if res == nil || res.ResultVariant == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := &models.PatchEntryRequest{
|
||||||
|
Variants: map[string]*models.EntryVariant{
|
||||||
|
"readability_full_text": res.ResultVariant,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch, map[string]interface{}{
|
||||||
|
"variant_key": "readability_full_text",
|
||||||
|
"length": len(res.ResultVariant.TextValue),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. AI Summary & AI Enrich Handler
|
||||||
|
aiHandler := func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
entryID, _ := job.Payload["entry_id"].(string)
|
||||||
|
if entryID == "" {
|
||||||
|
return nil, nil, fmt.Errorf("missing entry_id in job payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := database.GetEntry(entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("entry not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, prompts := ai.LoadConfig(database)
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, nil, fmt.Errorf("AI features are disabled. Please enable AI in Settings > AI & Integrations")
|
||||||
|
}
|
||||||
|
if cfg.Provider != "ollama" && strings.TrimSpace(cfg.APIKey) == "" {
|
||||||
|
return nil, nil, fmt.Errorf("AI API key is missing. Please configure an API key in Settings > AI & Integrations")
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt, _ := job.Payload["prompt"].(string)
|
||||||
|
if prompt == "" {
|
||||||
|
prompt, _ = job.Payload["custom_prompt"].(string)
|
||||||
|
}
|
||||||
|
if prompt == "" {
|
||||||
|
for _, p := range prompts {
|
||||||
|
if p.IsDefault {
|
||||||
|
prompt = p.Prompt
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prompt == "" && len(prompts) > 0 {
|
||||||
|
prompt = prompts[0].Prompt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prompt == "" {
|
||||||
|
prompt = "You are an expert technical editor. Summarize the following article in exactly 3 concise bullet points, followed by a bold 'Key Takeaway:' sentence.\n\nTitle: {{title}}\nFeed: {{feed_title}}\nContent:\n{{content}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
vKey, _ := job.Payload["variant_key"].(string)
|
||||||
|
if vKey == "" {
|
||||||
|
vKey = "ai_summary"
|
||||||
|
}
|
||||||
|
cType, _ := job.Payload["content_type"].(string)
|
||||||
|
if cType == "" {
|
||||||
|
cType = "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
resultText, err := aiClient.GenerateEnrichment(ctx, cfg, prompt, *entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("ai generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
variant := models.EntryVariant{
|
||||||
|
EntryID: entry.ID,
|
||||||
|
VariantKey: vKey,
|
||||||
|
ContentType: cType,
|
||||||
|
TextValue: resultText,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"model": cfg.Model,
|
||||||
|
"provider": cfg.Provider,
|
||||||
|
"prompt_used": prompt,
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := &models.PatchEntryRequest{
|
||||||
|
Variants: map[string]*models.EntryVariant{
|
||||||
|
vKey: &variant,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch, map[string]interface{}{
|
||||||
|
"variant_key": vKey,
|
||||||
|
"model": cfg.Model,
|
||||||
|
"word_count": len(strings.Fields(resultText)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.RegisterHandler("ai_summary", aiHandler)
|
||||||
|
engine.RegisterHandler("ai_enrich", aiHandler)
|
||||||
|
|
||||||
|
// 3. Add Tags Handler
|
||||||
|
engine.RegisterHandler("add_tags", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
entryID, _ := job.Payload["entry_id"].(string)
|
||||||
|
if entryID == "" {
|
||||||
|
return nil, nil, fmt.Errorf("missing entry_id in job payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagsToAdd []string
|
||||||
|
if rawTags, ok := job.Payload["tags"].([]interface{}); ok {
|
||||||
|
for _, t := range rawTags {
|
||||||
|
tagsToAdd = append(tagsToAdd, fmt.Sprintf("%v", t))
|
||||||
|
}
|
||||||
|
} else if rawTagsStr, ok := job.Payload["tags"].(string); ok {
|
||||||
|
for _, t := range strings.Split(rawTagsStr, ",") {
|
||||||
|
if tr := strings.TrimSpace(t); tr != "" {
|
||||||
|
tagsToAdd = append(tagsToAdd, tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := database.GetEntry(entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("entry not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingTags := make(map[string]bool)
|
||||||
|
if entry.Metadata != nil {
|
||||||
|
if curr, ok := entry.Metadata["tags"].([]interface{}); ok {
|
||||||
|
for _, t := range curr {
|
||||||
|
existingTags[fmt.Sprintf("%v", t)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tagsToAdd {
|
||||||
|
existingTags[t] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []string
|
||||||
|
for t := range existingTags {
|
||||||
|
merged = append(merged, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := &models.PatchEntryRequest{
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"tags": merged,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch, map[string]interface{}{"tags": merged}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Mark Read Handler
|
||||||
|
engine.RegisterHandler("mark_read", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
read := true
|
||||||
|
return &models.PatchEntryRequest{IsRead: &read}, map[string]interface{}{"is_read": true}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Mark Starred Handler
|
||||||
|
engine.RegisterHandler("mark_starred", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
starred := true
|
||||||
|
return &models.PatchEntryRequest{IsStarred: &starred}, map[string]interface{}{"is_starred": true}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimiter tracks login attempts per IP to prevent brute-force attacks
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts map[string][]time.Time
|
||||||
|
limit int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
attempts: make(map[string][]time.Time),
|
||||||
|
limit: limit,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Allow(key string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-rl.window)
|
||||||
|
|
||||||
|
// Filter out expired timestamps
|
||||||
|
valid := make([]time.Time, 0)
|
||||||
|
for _, t := range rl.attempts[key] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
valid = append(valid, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) >= rl.limit {
|
||||||
|
rl.attempts[key] = valid
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
valid = append(valid, now)
|
||||||
|
rl.attempts[key] = valid
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
|
||||||
|
return strings.TrimSpace(xrip)
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Handlers
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := getClientIP(r)
|
||||||
|
if !h.loginLimiter.Allow(ip) {
|
||||||
|
h.writeError(w, http.StatusTooManyRequests, "too many login attempts, please try again in a minute")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.auth.Authenticate(req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusUnauthorized, "invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "rsspipe_token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
user, _ := h.db.GetUserByUsername(req.Username)
|
||||||
|
h.writeJSON(w, http.StatusOK, models.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
User: *user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "rsspipe_token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok {
|
||||||
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.db.GetUserByUsername(claims.Username)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok {
|
||||||
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.ChangePasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.auth.UpdatePassword(claims.UserID, req.OldPassword, req.NewPassword); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "password updated successfully"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/services"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry Handlers
|
||||||
|
func (h *Handler) ListEntries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||||
|
|
||||||
|
filter := db.EntryFilter{
|
||||||
|
View: q.Get("view"),
|
||||||
|
FeedID: q.Get("feed_id"),
|
||||||
|
CategoryID: q.Get("category_id"),
|
||||||
|
Search: q.Get("search"),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, total, err := h.entryService.ListEntries(r.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []models.FeedEntry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"entries": entries,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
entry, err := h.entryService.GetEntry(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchEntry handles atomic partial updates to an entry and returns the updated entry + transition diff
|
||||||
|
func (h *Handler) PatchEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req models.PatchEntryRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, diff, err := h.entryService.PatchEntry(r.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"entry": updated,
|
||||||
|
"diff": diff,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateEntryRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var body models.UpdateEntryReadRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _, err := h.entryService.PatchEntry(r.Context(), id, models.PatchEntryRequest{
|
||||||
|
IsRead: &body.IsRead,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": updated.IsRead})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateEntryStar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var body models.UpdateEntryStarRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _, err := h.entryService.PatchEntry(r.Context(), id, models.PatchEntryRequest{
|
||||||
|
IsStarred: &body.IsStarred,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": updated.IsStarred})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body models.MarkAllReadRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
|
||||||
|
if err := h.entryService.MarkAllRead(r.Context(), body.FeedID, body.CategoryID); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "marked all as read"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrapeEntry triggers readability full text extraction (immediate response for UI, async if ?async=true)
|
||||||
|
func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
entry, err := h.entryService.GetEntry(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idempotencyKey := services.GenerateIdempotencyKey("readability_scrape", id, "manual", entry.CreatedAt.Format("20060102150405"))
|
||||||
|
job, err := h.jobService.Enqueue(r.Context(), "readability_scrape", map[string]interface{}{
|
||||||
|
"entry_id": id,
|
||||||
|
"url": entry.URL,
|
||||||
|
}, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Query().Get("async") == "true" {
|
||||||
|
h.writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||||
|
"job_id": job.ID,
|
||||||
|
"status": job.Status,
|
||||||
|
"message": "Scrape task queued asynchronously",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.jobEngine == nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "job engine not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patch, result, err := h.jobEngine.ExecuteDirect(r.Context(), "readability_scrape", job.Payload)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if patch != nil {
|
||||||
|
updated, _, patchErr := h.entryService.PatchEntry(r.Context(), id, *patch)
|
||||||
|
if patchErr != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, patchErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.db.UpdateJob(models.Job{
|
||||||
|
ID: job.ID,
|
||||||
|
Status: "completed",
|
||||||
|
Progress: 100,
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
h.writeJSON(w, http.StatusOK, updated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := h.entryService.GetEntry(r.Context(), id)
|
||||||
|
h.writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAISummary generates an AI summary (immediate response for UI, async if ?async=true)
|
||||||
|
func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
entry, err := h.entryService.GetEntry(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "entry not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Prompt string `json:"prompt,omitempty"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
|
||||||
|
idempotencyKey := services.GenerateIdempotencyKey("ai_summary", id, "manual", entry.CreatedAt.Format("20060102150405"))
|
||||||
|
job, err := h.jobService.Enqueue(r.Context(), "ai_summary", map[string]interface{}{
|
||||||
|
"entry_id": id,
|
||||||
|
"prompt": body.Prompt,
|
||||||
|
}, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.URL.Query().Get("async") == "true" {
|
||||||
|
h.writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||||
|
"job_id": job.ID,
|
||||||
|
"status": job.Status,
|
||||||
|
"message": "AI summary task queued asynchronously",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.jobEngine == nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, "job engine not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patch, result, err := h.jobEngine.ExecuteDirect(r.Context(), "ai_summary", job.Payload)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("AI summary generation failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if patch != nil {
|
||||||
|
updated, _, patchErr := h.entryService.PatchEntry(r.Context(), id, *patch)
|
||||||
|
if patchErr != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, patchErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.db.UpdateJob(models.Job{
|
||||||
|
ID: job.ID,
|
||||||
|
Status: "completed",
|
||||||
|
Progress: 100,
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
h.writeJSON(w, http.StatusOK, updated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := h.entryService.GetEntry(r.Context(), id)
|
||||||
|
h.writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
shareUUID, err := h.entryService.ToggleShare(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, _ := h.entryService.GetEntry(r.Context(), id)
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"shared": shareUUID != nil,
|
||||||
|
"share_uuid": shareUUID,
|
||||||
|
"entry": entry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListEntryVariants(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
variants, err := h.db.ListVariantsForEntry(id)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, variants)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetVariantBlobStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
key := chi.URLParam(r, "key")
|
||||||
|
variant, err := h.db.GetVariant(id, key)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusNotFound, "variant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if variant.BlobPath == "" {
|
||||||
|
h.writeError(w, http.StatusNotFound, "variant has no binary media blob")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFile(w, r, variant.BlobPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewSharedEntry renders a styled standalone HTML page for an explicitly shared entry (strictly checked)
|
||||||
|
func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uuidStr := chi.URLParam(r, "uuid")
|
||||||
|
entry, err := h.entryService.GetSharedEntry(r.Context(), uuidStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Shared article not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render standalone styled HTML
|
||||||
|
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>%s - RSSPipe</title>
|
||||||
|
<style>
|
||||||
|
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --muted: #94a3b8; --accent: #38bdf8; }
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root { --bg: #f8fafc; --card: #ffffff; --text: #0f172a; --muted: #64748b; --accent: #0284c7; }
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; margin: 0; padding: 2rem 1rem; display: flex; justify-content: center; }
|
||||||
|
.container { max-width: 760px; width: 100%%; background: var(--card); border-radius: 12px; padding: 2.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
||||||
|
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; border-bottom: 1px solid rgba(148, 163, 184, 0.2); padding-bottom: 1rem; }
|
||||||
|
h1 { margin-top: 0; font-size: 2rem; line-height: 1.3; }
|
||||||
|
h1 a { color: inherit; text-decoration: none; }
|
||||||
|
h1 a:hover { color: var(--accent); }
|
||||||
|
.content img { max-width: 100%%; height: auto; border-radius: 8px; }
|
||||||
|
.content pre { background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
||||||
|
.footer { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(148, 163, 184, 0.2); font-size: 0.85rem; color: var(--muted); text-align: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="meta">
|
||||||
|
<span><strong>%s</strong></span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>%s</span>
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
<h1><a href="%s" target="_blank" rel="noopener noreferrer">%s ↗</a></h1>
|
||||||
|
<div class="content">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
Shared via <a href="/" style="color: var(--accent); text-decoration: none;">RSSPipe Information Gateway</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
entry.Title,
|
||||||
|
entry.FeedTitle,
|
||||||
|
entry.PublishedAt.Format("Jan 02, 2006 15:04"),
|
||||||
|
func() string {
|
||||||
|
if entry.Author != "" {
|
||||||
|
return "<span>•</span><span>By " + entry.Author + "</span>"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(),
|
||||||
|
entry.URL,
|
||||||
|
entry.Title,
|
||||||
|
entry.Content,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte(html))
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Category Handlers
|
||||||
|
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cats, err := h.feedService.ListCategories(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cats == nil {
|
||||||
|
cats = []models.Category{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, cats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "valid category title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := h.feedService.CreateCategory(r.Context(), strings.TrimSpace(body.Title), body.ParentID)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusCreated, cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.feedService.DeleteCategory(r.Context(), id); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "category deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed Handlers
|
||||||
|
func (h *Handler) ListFeeds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
feeds, err := h.feedService.ListFeeds(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if feeds == nil {
|
||||||
|
feeds = []models.Feed{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, feeds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.CreateFeedRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.FeedURL) == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "valid feed_url is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := h.feedService.CreateFeed(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusCreated, feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req models.UpdateFeedRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := h.feedService.UpdateFeed(r.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.feedService.DeleteFeed(r.Context(), id); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "feed deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RefreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
f, err := h.feedService.RefreshFeed(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.feedService.RefreshAllFeeds(r.Context())
|
||||||
|
h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.feedService.GetStats(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPML Handlers
|
||||||
|
func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "file field is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdCount, totalParsed, err := h.feedService.ImportOPML(r.Context(), data)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"imported_count": createdCount,
|
||||||
|
"total_parsed": totalParsed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) {
|
||||||
|
xmlData, err := h.feedService.ExportOPML(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="rsspipe-subscriptions.opml"`))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(xmlData)
|
||||||
|
}
|
||||||
+70
-935
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||||
|
|
||||||
|
filter := models.JobFilter{
|
||||||
|
Type: q.Get("type"),
|
||||||
|
Status: q.Get("status"),
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := h.jobService.ListJobs(r.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if jobs == nil {
|
||||||
|
jobs = []models.Job{}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"jobs": jobs,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
job, err := h.jobService.GetJob(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
h.writeError(w, http.StatusNotFound, "job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CancelJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.jobService.CancelJob(r.Context(), id); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "job cancelled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RetryJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.jobService.RetryJob(r.Context(), id); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "job queued for retry"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rules API Handlers
|
||||||
|
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rulesList, err := h.db.ListRules()
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rulesList == nil {
|
||||||
|
rulesList = []models.Rule{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, rulesList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body models.Rule
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid rule payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
if body.Name == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "rule name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Trigger == "" {
|
||||||
|
body.Trigger = "on_update"
|
||||||
|
}
|
||||||
|
if body.MatchMode == "" {
|
||||||
|
body.MatchMode = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rules.ValidateRuleSafety(body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusCreated, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var body models.Rule
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid rule payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
if body.Name == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "rule name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rules.ValidateRuleSafety(body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.db.DeleteRule(id); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _, err := h.db.ListEntries(db.EntryFilter{Limit: 200})
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eval := rules.NewEvaluator()
|
||||||
|
matchedCount := 0
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if eval.Evaluate(*rule, entry) {
|
||||||
|
matchedCount++
|
||||||
|
if h.bus != nil {
|
||||||
|
h.bus.Publish(r.Context(), events.Event{
|
||||||
|
Type: events.EventManualTrigger,
|
||||||
|
Entry: entry,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "rule applied to matching articles",
|
||||||
|
"matched_count": matchedCount,
|
||||||
|
"queued_jobs": matchedCount * len(rule.Actions),
|
||||||
|
})
|
||||||
|
}
|
||||||
+24
-4
@@ -20,10 +20,10 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
|||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Compress(5))
|
r.Use(middleware.Compress(5))
|
||||||
|
|
||||||
// CORS configuration for local development & PWA
|
// CORS configuration: Restricted to localhost dev ports and same-origin
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080", "*"},
|
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080"},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
@@ -43,8 +43,14 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(authService.Middleware)
|
r.Use(authService.Middleware)
|
||||||
|
|
||||||
|
// Real-time Event Stream (K8s Watch pattern via SSE)
|
||||||
|
r.Get("/events/stream", h.StreamEvents)
|
||||||
|
|
||||||
|
// Auth & Profile
|
||||||
r.Get("/auth/me", h.GetMe)
|
r.Get("/auth/me", h.GetMe)
|
||||||
r.Put("/auth/password", h.UpdatePassword)
|
r.Put("/auth/password", h.UpdatePassword)
|
||||||
|
r.Get("/user/preferences", h.GetUserPreferences)
|
||||||
|
r.Put("/user/preferences", h.UpdateUserPreferences)
|
||||||
r.Get("/stats", h.GetStats)
|
r.Get("/stats", h.GetStats)
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
@@ -63,13 +69,22 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
|||||||
// Entries
|
// Entries
|
||||||
r.Get("/entries", h.ListEntries)
|
r.Get("/entries", h.ListEntries)
|
||||||
r.Get("/entries/{id}", h.GetEntry)
|
r.Get("/entries/{id}", h.GetEntry)
|
||||||
|
r.Patch("/entries/{id}", h.PatchEntry)
|
||||||
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
||||||
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
||||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
r.Post("/entries/{id}/share", h.ToggleShareEntry)
|
||||||
|
r.Post("/entries/{id}/ai-summary", h.GenerateAISummary)
|
||||||
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
|
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
|
||||||
|
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||||
r.Get("/entries/{id}/variants", h.ListEntryVariants)
|
r.Get("/entries/{id}/variants", h.ListEntryVariants)
|
||||||
r.Get("/entries/{id}/variants/{key}/stream", h.GetVariantBlobStream)
|
r.Get("/entries/{id}/variants/{key}/stream", h.GetVariantBlobStream)
|
||||||
|
|
||||||
|
// Task Queue / Background Jobs
|
||||||
|
r.Get("/jobs", h.ListJobs)
|
||||||
|
r.Get("/jobs/{id}", h.GetJob)
|
||||||
|
r.Post("/jobs/{id}/cancel", h.CancelJob)
|
||||||
|
r.Post("/jobs/{id}/retry", h.RetryJob)
|
||||||
|
|
||||||
// Rules
|
// Rules
|
||||||
r.Get("/rules", h.ListRules)
|
r.Get("/rules", h.ListRules)
|
||||||
r.Post("/rules", h.CreateRule)
|
r.Post("/rules", h.CreateRule)
|
||||||
@@ -82,6 +97,11 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
|
|||||||
r.Put("/settings/ai", h.UpdateAISettings)
|
r.Put("/settings/ai", h.UpdateAISettings)
|
||||||
r.Post("/settings/ai/test", h.TestAIConnection)
|
r.Post("/settings/ai/test", h.TestAIConnection)
|
||||||
|
|
||||||
|
// Search History
|
||||||
|
r.Get("/search/history", h.GetSearchHistory)
|
||||||
|
r.Post("/search/history", h.RecordSearch)
|
||||||
|
r.Delete("/search/history", h.DeleteSearchHistory)
|
||||||
|
|
||||||
// OPML
|
// OPML
|
||||||
r.Post("/opml/import", h.ImportOPML)
|
r.Post("/opml/import", h.ImportOPML)
|
||||||
r.Get("/opml/export", h.ExportOPML)
|
r.Get("/opml/export", h.ExportOPML)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/ai"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/auth"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User Preferences Handlers
|
||||||
|
func (h *Handler) GetUserPreferences(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok {
|
||||||
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs, err := h.settingsService.GetPreferences(r.Context(), claims.Username)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, prefs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateUserPreferences(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok {
|
||||||
|
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var newPrefs map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&newPrefs); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid request payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.settingsService.UpdatePreferences(r.Context(), claims.Username, newPrefs)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Settings Handlers
|
||||||
|
func (h *Handler) GetAISettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := h.settingsService.GetAISettings(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateAISettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body models.AISettings
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := h.settingsService.UpdateAISettings(r.Context(), body)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body models.AISettings
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
|
||||||
|
cfg := ai.Config{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
Provider: body.Provider,
|
||||||
|
BaseURL: body.BaseURL,
|
||||||
|
APIKey: body.APIKey,
|
||||||
|
Model: body.Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Provider == "" {
|
||||||
|
cfg.Provider = "openai"
|
||||||
|
}
|
||||||
|
if cfg.BaseURL == "" {
|
||||||
|
cfg.BaseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
testEntry := models.FeedEntry{
|
||||||
|
Title: "RSSPipe Connection Test",
|
||||||
|
Content: "This is a quick test to verify connection to your AI provider.",
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, "Respond with 'Connection successful' only.", testEntry)
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
"latency_ms": latency,
|
||||||
|
"model": cfg.Model,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": strings.TrimSpace(res),
|
||||||
|
"latency_ms": latency,
|
||||||
|
"model": cfg.Model,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search History Handlers
|
||||||
|
func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query().Get("q")
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
items, err := h.db.GetSearchHistory(q, limit)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []models.SearchHistoryItem{}
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Keyword string `json:"keyword"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Keyword) == "" {
|
||||||
|
h.writeError(w, http.StatusBadRequest, "keyword is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.RecordSearch(body.Keyword); err != nil {
|
||||||
|
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "recorded"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kw := r.URL.Query().Get("keyword")
|
||||||
|
if kw != "" {
|
||||||
|
_ = h.db.DeleteSearchHistory(kw)
|
||||||
|
} else {
|
||||||
|
_ = h.db.ClearSearchHistory()
|
||||||
|
}
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SSEBroadcaster struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[chan []byte]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSEBroadcaster() *SSEBroadcaster {
|
||||||
|
return &SSEBroadcaster{
|
||||||
|
clients: make(map[chan []byte]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SSEBroadcaster) Subscribe() chan []byte {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
ch := make(chan []byte, 32)
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SSEBroadcaster) Unsubscribe(ch chan []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if _, ok := b.clients[ch]; ok {
|
||||||
|
delete(b.clients, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *SSEBroadcaster) Broadcast(event string, data interface{}) {
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("event: %s\ndata: %s\n\n", event, string(payload))
|
||||||
|
msgBytes := []byte(msg)
|
||||||
|
|
||||||
|
b.mu.RLock()
|
||||||
|
defer b.mu.RUnlock()
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- msgBytes:
|
||||||
|
default:
|
||||||
|
// Non-blocking drop if client buffer is full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEvents handles GET /api/events/stream
|
||||||
|
func (h *Handler) StreamEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
// Send initial connection packet
|
||||||
|
initialMsg := fmt.Sprintf("event: connected\ndata: {\"status\":\"connected\",\"time\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
_, _ = w.Write([]byte(initialMsg))
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
clientCh := h.broadcaster.Subscribe()
|
||||||
|
defer h.broadcaster.Unsubscribe(clientCh)
|
||||||
|
|
||||||
|
heartbeat := time.NewTicker(25 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case <-heartbeat.C:
|
||||||
|
pingMsg := fmt.Sprintf("event: ping\ndata: {\"time\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
if _, err := w.Write([]byte(pingMsg)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case msg, ok := <-clientCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,14 +202,26 @@ func (c *Crawler) fetchSingleFeed(ctx context.Context, f models.Feed) error {
|
|||||||
zap.Int("new_entries", len(newEntries)),
|
zap.Int("new_entries", len(newEntries)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Publish on_post_created events for newly inserted entries
|
// Publish on_post_created and on_entry_transition events for newly inserted entries
|
||||||
if c.bus != nil {
|
if c.bus != nil {
|
||||||
for _, entry := range newEntries {
|
for _, entry := range newEntries {
|
||||||
entry.FeedTitle = f.Title
|
entry.FeedTitle = f.Title
|
||||||
entry.FeedSiteURL = f.SiteURL
|
entry.FeedSiteURL = f.SiteURL
|
||||||
|
diff := &models.EntryDiff{
|
||||||
|
FieldsChanged: []string{"id", "title", "content", "url"},
|
||||||
|
}
|
||||||
c.bus.Publish(ctx, events.Event{
|
c.bus.Publish(ctx, events.Event{
|
||||||
Type: events.EventPostCreated,
|
Type: events.EventPostCreated,
|
||||||
Entry: entry,
|
Entry: entry,
|
||||||
|
NewEntry: &entry,
|
||||||
|
Diff: diff,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
c.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventEntryTransition,
|
||||||
|
Entry: entry,
|
||||||
|
NewEntry: &entry,
|
||||||
|
Diff: diff,
|
||||||
Depth: 0,
|
Depth: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+531
-31
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,7 +25,8 @@ func InitDB(dbPath string) (*DB, error) {
|
|||||||
return nil, fmt.Errorf("failed to create db directory: %w", err)
|
return nil, fmt.Errorf("failed to create db directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
dsn := fmt.Sprintf("%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=cache_size(-20000)", dbPath)
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
||||||
}
|
}
|
||||||
@@ -33,18 +35,6 @@ func InitDB(dbPath string) (*DB, error) {
|
|||||||
db.SetMaxIdleConns(5)
|
db.SetMaxIdleConns(5)
|
||||||
db.SetConnMaxLifetime(time.Hour)
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
pragmas := []string{
|
|
||||||
"PRAGMA journal_mode = WAL;",
|
|
||||||
"PRAGMA busy_timeout = 5000;",
|
|
||||||
"PRAGMA synchronous = NORMAL;",
|
|
||||||
"PRAGMA foreign_keys = ON;",
|
|
||||||
}
|
|
||||||
for _, p := range pragmas {
|
|
||||||
if _, err := db.Exec(p); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to set pragma %s: %w", p, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
database := &DB{DB: db}
|
database := &DB{DB: db}
|
||||||
if err := database.migrate(); err != nil {
|
if err := database.migrate(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||||
@@ -145,12 +135,48 @@ func (d *DB) migrate() error {
|
|||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
updated_at DATETIME NOT NULL
|
updated_at DATETIME NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
keyword TEXT PRIMARY KEY,
|
||||||
|
last_searched_at DATETIME NOT NULL,
|
||||||
|
search_count INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_time ON search_history(last_searched_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
result TEXT DEFAULT '{}',
|
||||||
|
error_message TEXT DEFAULT '',
|
||||||
|
attempts INTEGER DEFAULT 0,
|
||||||
|
max_attempts INTEGER DEFAULT 3,
|
||||||
|
idempotency_key TEXT UNIQUE,
|
||||||
|
run_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_status_run_at ON jobs(status, run_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jobs_idempotency ON jobs(idempotency_key);
|
||||||
`
|
`
|
||||||
if _, err := d.Exec(schema); err != nil {
|
if _, err := d.Exec(schema); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Migration for timeout_seconds column if older DB exists
|
// Migration for timeout_seconds column if older DB exists
|
||||||
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
|
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
|
||||||
|
// Migration for icon_url column if older DB exists
|
||||||
|
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN icon_url TEXT DEFAULT ''")
|
||||||
|
// Backfill existing feeds with favicons if empty
|
||||||
|
_, _ = d.Exec(`
|
||||||
|
UPDATE feeds
|
||||||
|
SET icon_url = 'https://www.google.com/s2/favicons?domain=' || REPLACE(REPLACE(REPLACE(site_url, 'https://', ''), 'http://', ''), '/', '') || '&sz=64'
|
||||||
|
WHERE (icon_url IS NULL OR icon_url = '') AND site_url IS NOT NULL AND site_url != ''
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Migration for preferences column on users table if older DB exists
|
||||||
|
_, _ = d.Exec("ALTER TABLE users ADD COLUMN preferences TEXT DEFAULT '{}'")
|
||||||
|
|
||||||
// Migration for rules table schema if older table exists
|
// Migration for rules table schema if older table exists
|
||||||
var testCol string
|
var testCol string
|
||||||
@@ -183,13 +209,20 @@ func (d *DB) CountUsers() (int, error) {
|
|||||||
|
|
||||||
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
|
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
|
||||||
var u models.User
|
var u models.User
|
||||||
|
var prefsRaw sql.NullString
|
||||||
err := d.QueryRow(
|
err := d.QueryRow(
|
||||||
"SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?",
|
"SELECT id, username, password_hash, preferences, created_at, updated_at FROM users WHERE username = ?",
|
||||||
username,
|
username,
|
||||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt)
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &prefsRaw, &u.CreatedAt, &u.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if prefsRaw.Valid && prefsRaw.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(prefsRaw.String), &u.Preferences)
|
||||||
|
}
|
||||||
|
if u.Preferences == nil {
|
||||||
|
u.Preferences = make(map[string]interface{})
|
||||||
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,12 +232,13 @@ func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) {
|
|||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Username: username,
|
Username: username,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
|
Preferences: make(map[string]interface{}),
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
_, err := d.Exec(
|
_, err := d.Exec(
|
||||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO users (id, username, password_hash, preferences, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
u.ID, u.Username, u.PasswordHash, u.CreatedAt, u.UpdatedAt,
|
u.ID, u.Username, u.PasswordHash, "{}", u.CreatedAt, u.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -221,6 +255,19 @@ func (d *DB) UpdateUserPassword(userID, passwordHash string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateUserPreferences(userID string, prefs map[string]interface{}) error {
|
||||||
|
raw, err := json.Marshal(prefs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err = d.Exec(
|
||||||
|
"UPDATE users SET preferences = ?, updated_at = ? WHERE id = ?",
|
||||||
|
string(raw), now, userID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Category operations
|
// Category operations
|
||||||
func (d *DB) ListCategories() ([]models.Category, error) {
|
func (d *DB) ListCategories() ([]models.Category, error) {
|
||||||
query := `
|
query := `
|
||||||
@@ -294,11 +341,30 @@ func (d *DB) DeleteCategory(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveFavicon generates a high-quality favicon URL from a website or feed URL
|
||||||
|
func ResolveFavicon(siteURL, feedURL string) string {
|
||||||
|
targetURL := strings.TrimSpace(siteURL)
|
||||||
|
if targetURL == "" {
|
||||||
|
targetURL = strings.TrimSpace(feedURL)
|
||||||
|
}
|
||||||
|
if targetURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") {
|
||||||
|
targetURL = "https://" + targetURL
|
||||||
|
}
|
||||||
|
u, err := url.Parse(targetURL)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=64", u.Host)
|
||||||
|
}
|
||||||
|
|
||||||
// Feed operations
|
// Feed operations
|
||||||
func (d *DB) ListFeeds() ([]models.Feed, error) {
|
func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
f.id, f.title, f.feed_url, f.site_url, f.category_id,
|
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
|
||||||
COALESCE(c.title, '') as category_title,
|
COALESCE(c.title, '') as category_title,
|
||||||
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
||||||
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
||||||
@@ -321,10 +387,10 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
|
|||||||
var feeds []models.Feed
|
var feeds []models.Feed
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var f models.Feed
|
var f models.Feed
|
||||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
|
||||||
var lastFetched sql.NullTime
|
var lastFetched sql.NullTime
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
|
||||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -337,6 +403,12 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
|
|||||||
if siteURL.Valid {
|
if siteURL.Valid {
|
||||||
f.SiteURL = siteURL.String
|
f.SiteURL = siteURL.String
|
||||||
}
|
}
|
||||||
|
if iconURL.Valid {
|
||||||
|
f.IconURL = iconURL.String
|
||||||
|
}
|
||||||
|
if f.IconURL == "" {
|
||||||
|
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
|
||||||
|
}
|
||||||
if etag.Valid {
|
if etag.Valid {
|
||||||
f.ETag = etag.String
|
f.ETag = etag.String
|
||||||
}
|
}
|
||||||
@@ -358,7 +430,7 @@ func (d *DB) ListFeeds() ([]models.Feed, error) {
|
|||||||
func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
f.id, f.title, f.feed_url, f.site_url, f.category_id,
|
f.id, f.title, f.feed_url, f.site_url, COALESCE(f.icon_url, '') as icon_url, f.category_id,
|
||||||
COALESCE(c.title, '') as category_title,
|
COALESCE(c.title, '') as category_title,
|
||||||
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
f.is_virtual_digest, f.refresh_interval_minutes, COALESCE(f.timeout_seconds, 10),
|
||||||
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
f.etag, f.last_modified, f.last_fetched_at, f.fetch_error, f.created_at,
|
||||||
@@ -371,10 +443,10 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
|||||||
GROUP BY f.id
|
GROUP BY f.id
|
||||||
`
|
`
|
||||||
var f models.Feed
|
var f models.Feed
|
||||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
|
||||||
var lastFetched sql.NullTime
|
var lastFetched sql.NullTime
|
||||||
err := d.QueryRow(query, id).Scan(
|
err := d.QueryRow(query, id).Scan(
|
||||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID, &f.CategoryTitle,
|
||||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||||
)
|
)
|
||||||
@@ -388,6 +460,12 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
|||||||
if siteURL.Valid {
|
if siteURL.Valid {
|
||||||
f.SiteURL = siteURL.String
|
f.SiteURL = siteURL.String
|
||||||
}
|
}
|
||||||
|
if iconURL.Valid {
|
||||||
|
f.IconURL = iconURL.String
|
||||||
|
}
|
||||||
|
if f.IconURL == "" {
|
||||||
|
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
|
||||||
|
}
|
||||||
if etag.Valid {
|
if etag.Valid {
|
||||||
f.ETag = etag.String
|
f.ETag = etag.String
|
||||||
}
|
}
|
||||||
@@ -406,13 +484,13 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
|||||||
|
|
||||||
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
||||||
var f models.Feed
|
var f models.Feed
|
||||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
var catID, siteURL, iconURL, etag, lastMod, fetchErr sql.NullString
|
||||||
var lastFetched sql.NullTime
|
var lastFetched sql.NullTime
|
||||||
err := d.QueryRow(
|
err := d.QueryRow(
|
||||||
"SELECT id, title, feed_url, site_url, category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
|
"SELECT id, title, feed_url, site_url, COALESCE(icon_url, ''), category_id, is_virtual_digest, refresh_interval_minutes, COALESCE(timeout_seconds, 10), etag, last_modified, last_fetched_at, fetch_error, created_at FROM feeds WHERE feed_url = ?",
|
||||||
feedURL,
|
feedURL,
|
||||||
).Scan(
|
).Scan(
|
||||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID,
|
&f.ID, &f.Title, &f.FeedURL, &siteURL, &iconURL, &catID,
|
||||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
|
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
|
||||||
&lastFetched, &fetchErr, &f.CreatedAt,
|
&lastFetched, &fetchErr, &f.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -426,16 +504,26 @@ func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
|||||||
if siteURL.Valid {
|
if siteURL.Valid {
|
||||||
f.SiteURL = siteURL.String
|
f.SiteURL = siteURL.String
|
||||||
}
|
}
|
||||||
|
if iconURL.Valid {
|
||||||
|
f.IconURL = iconURL.String
|
||||||
|
}
|
||||||
|
if f.IconURL == "" {
|
||||||
|
f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL)
|
||||||
|
}
|
||||||
return &f, nil
|
return &f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*models.Feed, error) {
|
func (d *DB) CreateFeed(title, feedURL, siteURL, iconURL string, categoryID *string) (*models.Feed, error) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
if iconURL == "" {
|
||||||
|
iconURL = ResolveFavicon(siteURL, feedURL)
|
||||||
|
}
|
||||||
f := &models.Feed{
|
f := &models.Feed{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Title: title,
|
Title: title,
|
||||||
FeedURL: feedURL,
|
FeedURL: feedURL,
|
||||||
SiteURL: siteURL,
|
SiteURL: siteURL,
|
||||||
|
IconURL: iconURL,
|
||||||
CategoryID: categoryID,
|
CategoryID: categoryID,
|
||||||
RefreshIntervalMinutes: 30,
|
RefreshIntervalMinutes: 30,
|
||||||
TimeoutSeconds: 10,
|
TimeoutSeconds: 10,
|
||||||
@@ -446,8 +534,8 @@ func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*mo
|
|||||||
catID = *categoryID
|
catID = *categoryID
|
||||||
}
|
}
|
||||||
_, err := d.Exec(
|
_, err := d.Exec(
|
||||||
"INSERT INTO feeds (id, title, feed_url, site_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO feeds (id, title, feed_url, site_url, icon_url, category_id, refresh_interval_minutes, timeout_seconds, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
f.ID, f.Title, f.FeedURL, f.SiteURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
|
f.ID, f.Title, f.FeedURL, f.SiteURL, f.IconURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -466,6 +554,11 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
|
|||||||
title = *req.Title
|
title = *req.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
iconURL := feed.IconURL
|
||||||
|
if req.IconURL != nil {
|
||||||
|
iconURL = strings.TrimSpace(*req.IconURL)
|
||||||
|
}
|
||||||
|
|
||||||
catID := feed.CategoryID
|
catID := feed.CategoryID
|
||||||
if req.CategoryID != nil {
|
if req.CategoryID != nil {
|
||||||
if *req.CategoryID == "" {
|
if *req.CategoryID == "" {
|
||||||
@@ -492,9 +585,9 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
|
|||||||
|
|
||||||
_, err = d.Exec(`
|
_, err = d.Exec(`
|
||||||
UPDATE feeds
|
UPDATE feeds
|
||||||
SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
|
SET title = ?, icon_url = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`, title, cID, interval, timeout, id)
|
`, title, iconURL, cID, interval, timeout, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +729,8 @@ func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) {
|
|||||||
whereClauses = append(whereClauses, "e.is_read = 0")
|
whereClauses = append(whereClauses, "e.is_read = 0")
|
||||||
case "starred":
|
case "starred":
|
||||||
whereClauses = append(whereClauses, "e.is_starred = 1")
|
whereClauses = append(whereClauses, "e.is_starred = 1")
|
||||||
|
case "shared":
|
||||||
|
whereClauses = append(whereClauses, "e.share_uuid IS NOT NULL AND e.share_uuid != ''")
|
||||||
}
|
}
|
||||||
|
|
||||||
if filter.FeedID != "" {
|
if filter.FeedID != "" {
|
||||||
@@ -760,6 +855,34 @@ func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateEntryMetadata(id string, metadata map[string]interface{}) error {
|
||||||
|
metaBytes, err := json.Marshal(metadata)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = d.Exec("UPDATE feed_entries SET metadata = ? WHERE id = ?", string(metaBytes), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ToggleShareEntry(id string) (*string, error) {
|
||||||
|
entry, err := d.GetEntry(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if entry.ShareUUID != nil && *entry.ShareUUID != "" {
|
||||||
|
// Unshare
|
||||||
|
_, err := d.Exec("UPDATE feed_entries SET share_uuid = NULL WHERE id = ?", id)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Share
|
||||||
|
newUUID := uuid.New().String()
|
||||||
|
_, err = d.Exec("UPDATE feed_entries SET share_uuid = ? WHERE id = ?", newUUID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &newUUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) MarkAllRead(feedID, categoryID *string) error {
|
func (d *DB) MarkAllRead(feedID, categoryID *string) error {
|
||||||
if feedID != nil && *feedID != "" {
|
if feedID != nil && *feedID != "" {
|
||||||
_, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID)
|
_, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID)
|
||||||
@@ -1066,6 +1189,7 @@ func (d *DB) GetStats() (*models.Stats, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE share_uuid IS NOT NULL AND share_uuid != ''").Scan(&s.TotalShared)
|
||||||
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
|
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1116,3 +1240,379 @@ func (d *DB) GetSettingsMap() (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search History operations
|
||||||
|
func (d *DB) RecordSearch(keyword string) error {
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
if keyword == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := d.Exec(`
|
||||||
|
INSERT INTO search_history (keyword, last_searched_at, search_count)
|
||||||
|
VALUES (?, ?, 1)
|
||||||
|
ON CONFLICT(keyword) DO UPDATE SET
|
||||||
|
last_searched_at = excluded.last_searched_at,
|
||||||
|
search_count = search_history.search_count + 1
|
||||||
|
`, keyword, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetSearchHistory(query string, limit int) ([]models.SearchHistoryItem, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if query != "" {
|
||||||
|
rows, err = d.Query(`
|
||||||
|
SELECT keyword, last_searched_at, search_count
|
||||||
|
FROM search_history
|
||||||
|
WHERE keyword LIKE ?
|
||||||
|
ORDER BY last_searched_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, "%"+query+"%", limit)
|
||||||
|
} else {
|
||||||
|
rows, err = d.Query(`
|
||||||
|
SELECT keyword, last_searched_at, search_count
|
||||||
|
FROM search_history
|
||||||
|
ORDER BY last_searched_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, limit)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = rows.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var items []models.SearchHistoryItem
|
||||||
|
for rows.Next() {
|
||||||
|
var item models.SearchHistoryItem
|
||||||
|
if err := rows.Scan(&item.Keyword, &item.LastSearchedAt, &item.SearchCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteSearchHistory(keyword string) error {
|
||||||
|
_, err := d.Exec("DELETE FROM search_history WHERE keyword = ?", keyword)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ClearSearchHistory() error {
|
||||||
|
_, err := d.Exec("DELETE FROM search_history")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job Queue Operations
|
||||||
|
func (d *DB) CreateJob(job models.Job) (*models.Job, error) {
|
||||||
|
if job.ID == "" {
|
||||||
|
job.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if job.CreatedAt.IsZero() {
|
||||||
|
job.CreatedAt = now
|
||||||
|
}
|
||||||
|
job.UpdatedAt = now
|
||||||
|
if job.RunAt.IsZero() {
|
||||||
|
job.RunAt = now
|
||||||
|
}
|
||||||
|
if job.Status == "" {
|
||||||
|
job.Status = "pending"
|
||||||
|
}
|
||||||
|
if job.MaxAttempts <= 0 {
|
||||||
|
job.MaxAttempts = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(job.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal job payload: %w", err)
|
||||||
|
}
|
||||||
|
resultBytes, _ := json.Marshal(job.Result)
|
||||||
|
if len(resultBytes) == 0 {
|
||||||
|
resultBytes = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
var idemKey sql.NullString
|
||||||
|
if job.IdempotencyKey != "" {
|
||||||
|
idemKey = sql.NullString{String: job.IdempotencyKey, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO jobs (
|
||||||
|
id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
_, err = d.Exec(
|
||||||
|
query,
|
||||||
|
job.ID, job.Type, string(payloadBytes), job.Status, job.Progress,
|
||||||
|
string(resultBytes), job.ErrorMessage, job.Attempts, job.MaxAttempts,
|
||||||
|
idemKey, job.RunAt, job.CreatedAt, job.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &job, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetJob(id string) (*models.Job, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
var j models.Job
|
||||||
|
var payloadStr, resultStr, errorMsg string
|
||||||
|
var idemKey sql.NullString
|
||||||
|
|
||||||
|
err := d.QueryRow(query, id).Scan(
|
||||||
|
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
|
||||||
|
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
|
||||||
|
_ = json.Unmarshal([]byte(resultStr), &j.Result)
|
||||||
|
j.ErrorMessage = errorMsg
|
||||||
|
if idemKey.Valid {
|
||||||
|
j.IdempotencyKey = idemKey.String
|
||||||
|
}
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) GetJobByIdempotencyKey(key string) (*models.Job, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE idempotency_key = ?
|
||||||
|
`
|
||||||
|
var j models.Job
|
||||||
|
var payloadStr, resultStr, errorMsg string
|
||||||
|
var idemKey sql.NullString
|
||||||
|
|
||||||
|
err := d.QueryRow(query, key).Scan(
|
||||||
|
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
|
||||||
|
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
|
||||||
|
_ = json.Unmarshal([]byte(resultStr), &j.Result)
|
||||||
|
j.ErrorMessage = errorMsg
|
||||||
|
if idemKey.Valid {
|
||||||
|
j.IdempotencyKey = idemKey.String
|
||||||
|
}
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ListJobs(filter models.JobFilter) ([]models.Job, error) {
|
||||||
|
if filter.Limit <= 0 {
|
||||||
|
filter.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
var conditions []string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if filter.Type != "" {
|
||||||
|
conditions = append(conditions, "type = ?")
|
||||||
|
args = append(args, filter.Type)
|
||||||
|
}
|
||||||
|
if filter.Status != "" {
|
||||||
|
conditions = append(conditions, "status = ?")
|
||||||
|
args = append(args, filter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
whereClause := ""
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
whereClause = "WHERE " + strings.Join(conditions, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
|
||||||
|
FROM jobs
|
||||||
|
%s
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, whereClause)
|
||||||
|
|
||||||
|
args = append(args, filter.Limit, filter.Offset)
|
||||||
|
rows, err := d.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = rows.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var jobs []models.Job
|
||||||
|
for rows.Next() {
|
||||||
|
var j models.Job
|
||||||
|
var payloadStr, resultStr, errorMsg string
|
||||||
|
var idemKey sql.NullString
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
|
||||||
|
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
|
||||||
|
_ = json.Unmarshal([]byte(resultStr), &j.Result)
|
||||||
|
j.ErrorMessage = errorMsg
|
||||||
|
if idemKey.Valid {
|
||||||
|
j.IdempotencyKey = idemKey.String
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateJob(job models.Job) error {
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
payloadBytes, _ := json.Marshal(job.Payload)
|
||||||
|
resultBytes, _ := json.Marshal(job.Result)
|
||||||
|
if len(resultBytes) == 0 {
|
||||||
|
resultBytes = []byte("{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE jobs SET
|
||||||
|
status = ?, progress = ?, result = ?, error_message = ?,
|
||||||
|
attempts = ?, run_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
_, err := d.Exec(
|
||||||
|
query,
|
||||||
|
job.Status, job.Progress, string(resultBytes), job.ErrorMessage,
|
||||||
|
job.Attempts, job.RunAt, job.UpdatedAt, job.ID,
|
||||||
|
)
|
||||||
|
_ = payloadBytes
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ClaimNextJob(types []string) (*models.Job, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
var typeFilter string
|
||||||
|
var args []interface{}
|
||||||
|
args = append(args, now)
|
||||||
|
|
||||||
|
if len(types) > 0 {
|
||||||
|
placeholders := make([]string, len(types))
|
||||||
|
for i, t := range types {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args = append(args, t)
|
||||||
|
}
|
||||||
|
typeFilter = fmt.Sprintf("AND type IN (%s)", strings.Join(placeholders, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id, type, payload, status, progress, result, error_message, attempts, max_attempts, idempotency_key, run_at, created_at, updated_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE status = 'pending' AND run_at <= ? %s
|
||||||
|
ORDER BY run_at ASC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
`, typeFilter)
|
||||||
|
|
||||||
|
var j models.Job
|
||||||
|
var payloadStr, resultStr, errorMsg string
|
||||||
|
var idemKey sql.NullString
|
||||||
|
|
||||||
|
err := d.QueryRow(query, args...).Scan(
|
||||||
|
&j.ID, &j.Type, &payloadStr, &j.Status, &j.Progress, &resultStr,
|
||||||
|
&errorMsg, &j.Attempts, &j.MaxAttempts, &idemKey, &j.RunAt, &j.CreatedAt, &j.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically mark as running
|
||||||
|
updateRes, err := d.Exec("UPDATE jobs SET status = 'running', updated_at = ? WHERE id = ? AND status = 'pending'", now, j.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rowsAffected, _ := updateRes.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.Unmarshal([]byte(payloadStr), &j.Payload)
|
||||||
|
_ = json.Unmarshal([]byte(resultStr), &j.Result)
|
||||||
|
j.ErrorMessage = errorMsg
|
||||||
|
j.Status = "running"
|
||||||
|
if idemKey.Valid {
|
||||||
|
j.IdempotencyKey = idemKey.String
|
||||||
|
}
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) ResetRunningJobsToPending() error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := d.Exec("UPDATE jobs SET status = 'pending', updated_at = ? WHERE status = 'running'", now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) CancelJob(id string) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := d.Exec("UPDATE jobs SET status = 'cancelled', updated_at = ? WHERE id = ? AND status IN ('pending', 'running')", now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSharedEntry strictly retrieves an entry by share_uuid only (prevents data leak of unshared articles)
|
||||||
|
func (d *DB) GetSharedEntry(shareUUID string) (*models.FeedEntry, error) {
|
||||||
|
if shareUUID == "" {
|
||||||
|
return nil, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
var e models.FeedEntry
|
||||||
|
var fTitle, fSiteURL, author, rawContent, metaStr, sUUID sql.NullString
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
|
||||||
|
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
|
||||||
|
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
|
||||||
|
FROM feed_entries e
|
||||||
|
JOIN feeds f ON f.id = e.feed_id
|
||||||
|
WHERE e.share_uuid IS NOT NULL AND e.share_uuid != '' AND e.share_uuid = ?
|
||||||
|
`
|
||||||
|
err := d.QueryRow(query, shareUUID).Scan(
|
||||||
|
&e.ID, &e.FeedID, &fTitle, &fSiteURL,
|
||||||
|
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
|
||||||
|
&e.PublishedAt, &e.IsRead, &e.IsStarred, &sUUID, &metaStr, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fTitle.Valid {
|
||||||
|
e.FeedTitle = fTitle.String
|
||||||
|
}
|
||||||
|
if fSiteURL.Valid {
|
||||||
|
e.FeedSiteURL = fSiteURL.String
|
||||||
|
}
|
||||||
|
if author.Valid {
|
||||||
|
e.Author = author.String
|
||||||
|
}
|
||||||
|
if sUUID.Valid {
|
||||||
|
e.ShareUUID = &sUUID.String
|
||||||
|
}
|
||||||
|
|
||||||
|
variants, _ := d.ListVariantsForEntry(e.ID)
|
||||||
|
if len(variants) > 0 {
|
||||||
|
e.Variants = make(map[string]models.EntryVariant)
|
||||||
|
for _, v := range variants {
|
||||||
|
e.Variants[v.VariantKey] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestDatabaseOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Test Feed
|
// 3. Test Feed
|
||||||
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", &cat.ID)
|
feed, err := database.CreateFeed("Hacker News", "https://news.ycombinator.com/rss", "https://news.ycombinator.com", "", &cat.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateFeed failed: %v", err)
|
t.Fatalf("CreateFeed failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,21 @@ const (
|
|||||||
EventPostCreated EventType = "on_post_created"
|
EventPostCreated EventType = "on_post_created"
|
||||||
EventPostUpdated EventType = "on_post_updated"
|
EventPostUpdated EventType = "on_post_updated"
|
||||||
EventVariantUpdated EventType = "on_variant_updated"
|
EventVariantUpdated EventType = "on_variant_updated"
|
||||||
|
EventEntryTransition EventType = "on_entry_transition"
|
||||||
EventManualTrigger EventType = "manual"
|
EventManualTrigger EventType = "manual"
|
||||||
|
EventJobProgress EventType = "job_progress"
|
||||||
|
EventJobCompleted EventType = "job_completed"
|
||||||
|
EventJobFailed EventType = "job_failed"
|
||||||
|
EventPreferencesSync EventType = "preferences_updated"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventType
|
Type EventType
|
||||||
Entry models.FeedEntry
|
Entry models.FeedEntry
|
||||||
|
OldEntry *models.FeedEntry
|
||||||
|
NewEntry *models.FeedEntry
|
||||||
|
Diff *models.EntryDiff
|
||||||
|
Job *models.Job
|
||||||
VariantKey string // populated when Type == EventVariantUpdated
|
VariantKey string // populated when Type == EventVariantUpdated
|
||||||
Depth int
|
Depth int
|
||||||
DirectAttrs map[string]interface{}
|
DirectAttrs map[string]interface{}
|
||||||
|
|||||||
+106
-1
@@ -9,10 +9,18 @@ type User struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
|
Preferences map[string]interface{} `json:"preferences,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchHistoryItem tracks user search history for autocomplete
|
||||||
|
type SearchHistoryItem struct {
|
||||||
|
Keyword string `json:"keyword"`
|
||||||
|
LastSearchedAt time.Time `json:"last_searched_at"`
|
||||||
|
SearchCount int `json:"search_count"`
|
||||||
|
}
|
||||||
|
|
||||||
// Category represents a folder/tag grouping for feeds
|
// Category represents a folder/tag grouping for feeds
|
||||||
type Category struct {
|
type Category struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -29,6 +37,7 @@ type Feed struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
FeedURL string `json:"feed_url"`
|
FeedURL string `json:"feed_url"`
|
||||||
SiteURL string `json:"site_url"`
|
SiteURL string `json:"site_url"`
|
||||||
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
CategoryID *string `json:"category_id,omitempty"`
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
CategoryTitle string `json:"category_title,omitempty"`
|
CategoryTitle string `json:"category_title,omitempty"`
|
||||||
IsVirtualDigest bool `json:"is_virtual_digest"`
|
IsVirtualDigest bool `json:"is_virtual_digest"`
|
||||||
@@ -58,7 +67,7 @@ type FeedEntry struct {
|
|||||||
PublishedAt time.Time `json:"published_at"`
|
PublishedAt time.Time `json:"published_at"`
|
||||||
IsRead bool `json:"is_read"`
|
IsRead bool `json:"is_read"`
|
||||||
IsStarred bool `json:"is_starred"`
|
IsStarred bool `json:"is_starred"`
|
||||||
ShareUUID *string `json:"share_uuid,omitempty"`
|
ShareUUID *string `json:"share_uuid"`
|
||||||
Variants map[string]EntryVariant `json:"variants,omitempty"`
|
Variants map[string]EntryVariant `json:"variants,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -108,6 +117,7 @@ type Stats struct {
|
|||||||
TotalFeeds int `json:"total_feeds"`
|
TotalFeeds int `json:"total_feeds"`
|
||||||
TotalUnread int `json:"total_unread"`
|
TotalUnread int `json:"total_unread"`
|
||||||
TotalStarred int `json:"total_starred"`
|
TotalStarred int `json:"total_starred"`
|
||||||
|
TotalShared int `json:"total_shared"`
|
||||||
TotalCategories int `json:"total_categories"`
|
TotalCategories int `json:"total_categories"`
|
||||||
TotalRules int `json:"total_rules"`
|
TotalRules int `json:"total_rules"`
|
||||||
}
|
}
|
||||||
@@ -129,6 +139,7 @@ type CreateFeedRequest struct {
|
|||||||
FeedURL string `json:"feed_url"`
|
FeedURL string `json:"feed_url"`
|
||||||
CategoryID *string `json:"category_id,omitempty"`
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
|
IconURL *string `json:"icon_url,omitempty"`
|
||||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -137,6 +148,7 @@ type CreateFeedRequest struct {
|
|||||||
type UpdateFeedRequest struct {
|
type UpdateFeedRequest struct {
|
||||||
Title *string `json:"title,omitempty"`
|
Title *string `json:"title,omitempty"`
|
||||||
CategoryID *string `json:"category_id,omitempty"`
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
IconURL *string `json:"icon_url,omitempty"`
|
||||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -163,3 +175,96 @@ type ChangePasswordRequest struct {
|
|||||||
OldPassword string `json:"old_password"`
|
OldPassword string `json:"old_password"`
|
||||||
NewPassword string `json:"new_password"`
|
NewPassword string `json:"new_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Job represents a background task in the persistent job queue
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload map[string]interface{} `json:"payload"`
|
||||||
|
Status string `json:"status"` // 'pending', 'running', 'completed', 'failed', 'cancelled'
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Result map[string]interface{} `json:"result,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
Attempts int `json:"attempts"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
|
RunAt time.Time `json:"run_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobFilter is used to filter job listings
|
||||||
|
type JobFilter struct {
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchEntryRequest is the body for PATCH /api/entries/{id}
|
||||||
|
type PatchEntryRequest struct {
|
||||||
|
IsRead *bool `json:"is_read,omitempty"`
|
||||||
|
IsStarred *bool `json:"is_starred,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
Variants map[string]*EntryVariant `json:"variants,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntryDiff represents the delta between OldState and NewState of an Entry
|
||||||
|
type EntryDiff struct {
|
||||||
|
FieldsChanged []string `json:"fields_changed"` // e.g. ["is_read", "is_starred", "metadata.tags"]
|
||||||
|
VariantsAdded []string `json:"variants_added"` // e.g. ["readability_full_text"]
|
||||||
|
VariantsChanged []string `json:"variants_changed"` // e.g. ["ai_summary"]
|
||||||
|
VariantsRemoved []string `json:"variants_removed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasVariantAddition checks if a specific variant key was newly added in this transition
|
||||||
|
func (d EntryDiff) HasVariantAddition(variantKey string) bool {
|
||||||
|
for _, k := range d.VariantsAdded {
|
||||||
|
if k == variantKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasVariantChange checks if a specific variant key was modified in this transition
|
||||||
|
func (d EntryDiff) HasVariantChange(variantKey string) bool {
|
||||||
|
for _, k := range d.VariantsChanged {
|
||||||
|
if k == variantKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFieldChange checks if a specific top-level or metadata field changed
|
||||||
|
func (d EntryDiff) HasFieldChange(fieldName string) bool {
|
||||||
|
for _, f := range d.FieldsChanged {
|
||||||
|
if f == fieldName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptTemplate represents an AI prompt template
|
||||||
|
type PromptTemplate struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
IsDefault bool `json:"is_default,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AISettings represents AI provider configuration
|
||||||
|
type AISettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Prompts []PromptTemplate `json:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package rules
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
@@ -14,11 +17,16 @@ type WorkerDispatcher interface {
|
|||||||
Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int)
|
Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type JobEnqueuer interface {
|
||||||
|
Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
bus *events.EventBus
|
bus *events.EventBus
|
||||||
evaluator *Evaluator
|
evaluator *Evaluator
|
||||||
dispatcher WorkerDispatcher
|
dispatcher WorkerDispatcher
|
||||||
|
enqueuer JobEnqueuer
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +40,13 @@ func NewEngine(database *db.DB, bus *events.EventBus, dispatcher WorkerDispatche
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SetEnqueuer(enqueuer JobEnqueuer) {
|
||||||
|
e.enqueuer = enqueuer
|
||||||
|
}
|
||||||
|
|
||||||
// Start registers event bus subscriptions to evaluate rules reactively.
|
// Start registers event bus subscriptions to evaluate rules reactively.
|
||||||
func (e *Engine) Start(ctx context.Context) {
|
func (e *Engine) Start(ctx context.Context) {
|
||||||
|
e.bus.Subscribe(events.EventEntryTransition, e.handleTransitionEvent)
|
||||||
e.bus.Subscribe(events.EventPostCreated, e.handleEvent)
|
e.bus.Subscribe(events.EventPostCreated, e.handleEvent)
|
||||||
e.bus.Subscribe(events.EventPostUpdated, e.handleEvent)
|
e.bus.Subscribe(events.EventPostUpdated, e.handleEvent)
|
||||||
e.bus.Subscribe(events.EventVariantUpdated, e.handleEvent)
|
e.bus.Subscribe(events.EventVariantUpdated, e.handleEvent)
|
||||||
@@ -41,6 +54,44 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
e.log.Info("Rules engine started, listening for events")
|
e.log.Info("Rules engine started, listening for events")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) handleTransitionEvent(ctx context.Context, evt events.Event) error {
|
||||||
|
rules, err := e.db.ListRules()
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error("Failed to fetch rules for transition", zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newEntry := evt.NewEntry
|
||||||
|
if newEntry == nil {
|
||||||
|
newEntry = &evt.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rules {
|
||||||
|
if !r.IsActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check trigger type compatibility
|
||||||
|
trigger := r.Trigger
|
||||||
|
if trigger != "on_update" && trigger != "on_entry_transition" && trigger != "on_variant_updated" && trigger != "on_post_updated" && trigger != "on_post_created" && trigger != "variant_change" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.evaluator.EvaluateTransition(r, evt.OldEntry, newEntry, evt.Diff) {
|
||||||
|
e.log.Info("Transition rule matched",
|
||||||
|
zap.String("rule_id", r.ID),
|
||||||
|
zap.String("rule_name", r.Name),
|
||||||
|
zap.String("entry_id", newEntry.ID),
|
||||||
|
zap.Int("actions_count", len(r.Actions)),
|
||||||
|
)
|
||||||
|
|
||||||
|
e.executeRuleActions(ctx, r, *newEntry, evt.Depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
||||||
trigger := string(evt.Type)
|
trigger := string(evt.Type)
|
||||||
rules, err := e.db.GetActiveRules(trigger)
|
rules, err := e.db.GetActiveRules(trigger)
|
||||||
@@ -53,7 +104,6 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch full entry with latest variants if entry ID is present
|
|
||||||
entry := evt.Entry
|
entry := evt.Entry
|
||||||
if entry.ID != "" {
|
if entry.ID != "" {
|
||||||
if full, err := e.db.GetEntry(entry.ID); err == nil && full != nil {
|
if full, err := e.db.GetEntry(entry.ID); err == nil && full != nil {
|
||||||
@@ -62,7 +112,7 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
if e.evaluator.Evaluate(r, entry) {
|
if e.evaluator.EvaluateTransition(r, evt.OldEntry, &entry, evt.Diff) {
|
||||||
e.log.Info("Rule matched",
|
e.log.Info("Rule matched",
|
||||||
zap.String("rule_id", r.ID),
|
zap.String("rule_id", r.ID),
|
||||||
zap.String("rule_name", r.Name),
|
zap.String("rule_name", r.Name),
|
||||||
@@ -70,13 +120,43 @@ func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
|||||||
zap.Int("actions_count", len(r.Actions)),
|
zap.Int("actions_count", len(r.Actions)),
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, action := range r.Actions {
|
e.executeRuleActions(ctx, r, entry, evt.Depth)
|
||||||
if e.dispatcher != nil {
|
|
||||||
e.dispatcher.Dispatch(ctx, action, entry, evt.Depth+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) executeRuleActions(ctx context.Context, r models.Rule, entry models.FeedEntry, depth int) {
|
||||||
|
for _, action := range r.Actions {
|
||||||
|
// Prefer enqueuing async jobs to the persistent task queue if available
|
||||||
|
if e.enqueuer != nil {
|
||||||
|
payload := make(map[string]interface{})
|
||||||
|
if action.Params != nil {
|
||||||
|
for k, v := range action.Params {
|
||||||
|
payload[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload["entry_id"] = entry.ID
|
||||||
|
payload["feed_id"] = entry.FeedID
|
||||||
|
payload["rule_id"] = r.ID
|
||||||
|
payload["rule_name"] = r.Name
|
||||||
|
|
||||||
|
// Generate deterministic idempotency key
|
||||||
|
rawKey := fmt.Sprintf("%s:%s:%s:%d:%d", r.ID, entry.ID, action.Type, entry.CreatedAt.Unix(), entry.PublishedAt.Unix())
|
||||||
|
h := sha256.Sum256([]byte(rawKey))
|
||||||
|
idempotencyKey := hex.EncodeToString(h[:16])
|
||||||
|
|
||||||
|
_, err := e.enqueuer.Enqueue(ctx, action.Type, payload, idempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
e.log.Error("Failed to enqueue job for rule action",
|
||||||
|
zap.String("rule_id", r.ID),
|
||||||
|
zap.String("action", action.Type),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if e.dispatcher != nil {
|
||||||
|
e.dispatcher.Dispatch(ctx, action, entry, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+201
-27
@@ -2,6 +2,7 @@ package rules
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,8 +17,16 @@ func NewEvaluator() *Evaluator {
|
|||||||
return &Evaluator{}
|
return &Evaluator{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate checks whether a FeedEntry satisfies a Rule's conditions.
|
// Evaluate checks whether a single FeedEntry satisfies a Rule's conditions (legacy / static evaluation).
|
||||||
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
||||||
|
return e.EvaluateTransition(rule, nil, &entry, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateTransition evaluates a rule against the (OldState, NewState, EntryDiff) transition delta.
|
||||||
|
func (e *Evaluator) EvaluateTransition(rule models.Rule, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
||||||
|
if newEntry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if len(rule.Conditions) == 0 {
|
if len(rule.Conditions) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -25,7 +34,7 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
|||||||
matchMode := strings.ToLower(rule.MatchMode)
|
matchMode := strings.ToLower(rule.MatchMode)
|
||||||
if matchMode == "" || matchMode == "all" {
|
if matchMode == "" || matchMode == "all" {
|
||||||
for _, cond := range rule.Conditions {
|
for _, cond := range rule.Conditions {
|
||||||
if !e.evaluateCondition(cond, entry) {
|
if !e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,20 +43,109 @@ func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
|||||||
|
|
||||||
// matchMode == "any"
|
// matchMode == "any"
|
||||||
for _, cond := range rule.Conditions {
|
for _, cond := range rule.Conditions {
|
||||||
if e.evaluateCondition(cond, entry) {
|
if e.evaluateTransitionCondition(cond, oldEntry, newEntry, diff) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool {
|
func (e *Evaluator) evaluateTransitionCondition(cond models.RuleCondition, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
||||||
actualValue := e.resolveField(cond.Field, entry)
|
opLower := strings.ToLower(strings.TrimSpace(cond.Operator))
|
||||||
return e.compare(actualValue, cond.Operator, cond.Value)
|
|
||||||
|
// 1. Transition predicates: became_present, changed, removed
|
||||||
|
switch opLower {
|
||||||
|
case "became_present", "added":
|
||||||
|
return e.checkBecamePresent(cond.Field, oldEntry, newEntry, diff)
|
||||||
|
case "changed", "updated":
|
||||||
|
return e.checkFieldChanged(cond.Field, oldEntry, newEntry, diff)
|
||||||
|
case "removed", "deleted":
|
||||||
|
return e.checkFieldRemoved(cond.Field, oldEntry, newEntry, diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Standard state guards & value comparisons evaluated on NewState
|
||||||
|
actualValue := e.resolveField(cond.Field, newEntry)
|
||||||
|
return e.compare(actualValue, opLower, cond.Value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface{} {
|
func (e *Evaluator) checkBecamePresent(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
||||||
switch strings.ToLower(field) {
|
if newEntry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If diff is available, check direct addition
|
||||||
|
if strings.HasPrefix(field, "variants.") {
|
||||||
|
vKey := strings.TrimPrefix(field, "variants.")
|
||||||
|
if strings.Contains(vKey, ".") {
|
||||||
|
vKey = strings.Split(vKey, ".")[0]
|
||||||
|
}
|
||||||
|
if diff != nil && diff.HasVariantAddition(vKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Fallback comparison between oldEntry and newEntry
|
||||||
|
oldHas := oldEntry != nil && oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
|
||||||
|
newHas := newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
|
||||||
|
return !oldHas && newHas
|
||||||
|
}
|
||||||
|
|
||||||
|
if field == "metadata.tags" || field == "tags" {
|
||||||
|
oldTags := getTagsSlice(oldEntry)
|
||||||
|
newTags := getTagsSlice(newEntry)
|
||||||
|
return len(oldTags) == 0 && len(newTags) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
oldVal := e.resolveField(field, oldEntry)
|
||||||
|
newVal := e.resolveField(field, newEntry)
|
||||||
|
return (oldVal == nil || fmt.Sprintf("%v", oldVal) == "") && (newVal != nil && fmt.Sprintf("%v", newVal) != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) checkFieldChanged(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
||||||
|
if oldEntry == nil || newEntry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(field, "variants.") {
|
||||||
|
vKey := strings.TrimPrefix(field, "variants.")
|
||||||
|
if strings.Contains(vKey, ".") {
|
||||||
|
vKey = strings.Split(vKey, ".")[0]
|
||||||
|
}
|
||||||
|
if diff != nil && (diff.HasVariantAddition(vKey) || diff.HasVariantChange(vKey)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldVal := e.resolveField(field, oldEntry)
|
||||||
|
newVal := e.resolveField(field, newEntry)
|
||||||
|
return !reflect.DeepEqual(oldVal, newVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) checkFieldRemoved(field string, oldEntry *models.FeedEntry, newEntry *models.FeedEntry, diff *models.EntryDiff) bool {
|
||||||
|
if oldEntry == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(field, "variants.") {
|
||||||
|
vKey := strings.TrimPrefix(field, "variants.")
|
||||||
|
if diff != nil {
|
||||||
|
for _, r := range diff.VariantsRemoved {
|
||||||
|
if r == vKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldHas := oldEntry.Variants != nil && oldEntry.Variants[vKey].TextValue != ""
|
||||||
|
newHas := newEntry != nil && newEntry.Variants != nil && newEntry.Variants[vKey].TextValue != ""
|
||||||
|
return oldHas && !newHas
|
||||||
|
}
|
||||||
|
oldVal := e.resolveField(field, oldEntry)
|
||||||
|
newVal := e.resolveField(field, newEntry)
|
||||||
|
return (oldVal != nil && fmt.Sprintf("%v", oldVal) != "") && (newVal == nil || fmt.Sprintf("%v", newVal) == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Evaluator) resolveField(field string, entry *models.FeedEntry) interface{} {
|
||||||
|
if entry == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldLower := strings.ToLower(strings.TrimSpace(field))
|
||||||
|
switch fieldLower {
|
||||||
case "feed_id":
|
case "feed_id":
|
||||||
return entry.FeedID
|
return entry.FeedID
|
||||||
case "feed_title":
|
case "feed_title":
|
||||||
@@ -83,16 +181,34 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
|||||||
return entry.PublishedAt
|
return entry.PublishedAt
|
||||||
case "created_at":
|
case "created_at":
|
||||||
return entry.CreatedAt
|
return entry.CreatedAt
|
||||||
|
case "tags", "metadata.tags":
|
||||||
|
return getTagsSlice(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
|
// Variant resolution: "variants.ai_summary.word_count", "variants.ai_summary", "variants.readability_full_text"
|
||||||
if strings.HasPrefix(field, "variants.") {
|
if strings.HasPrefix(fieldLower, "variants.") {
|
||||||
variantKey := strings.TrimPrefix(field, "variants.")
|
path := strings.TrimPrefix(fieldLower, "variants.")
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
vKey := parts[0]
|
||||||
|
|
||||||
if entry.Variants != nil {
|
if entry.Variants != nil {
|
||||||
if v, ok := entry.Variants[variantKey]; ok {
|
if v, ok := entry.Variants[vKey]; ok {
|
||||||
|
if len(parts) > 1 {
|
||||||
|
subProp := parts[1]
|
||||||
|
if subProp == "word_count" {
|
||||||
|
return float64(len(strings.Fields(v.TextValue)))
|
||||||
|
}
|
||||||
|
if subProp == "char_count" || subProp == "length" {
|
||||||
|
return float64(len(v.TextValue))
|
||||||
|
}
|
||||||
|
if v.Metadata != nil {
|
||||||
|
return v.Metadata[subProp]
|
||||||
|
}
|
||||||
|
}
|
||||||
return v.TextValue
|
return v.TextValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -101,35 +217,85 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
|||||||
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
|
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
|
||||||
opLower := strings.ToLower(strings.TrimSpace(op))
|
opLower := strings.ToLower(strings.TrimSpace(op))
|
||||||
|
|
||||||
// Handle exists / not_exists operators
|
// Handle is_empty / is_present / exists / not_exists
|
||||||
if opLower == "exists" {
|
if opLower == "is_empty" || opLower == "not_exists" {
|
||||||
if actual == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if s, ok := actual.(string); ok && s == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if opLower == "not_exists" {
|
|
||||||
if actual == nil {
|
if actual == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if s, ok := actual.(string); ok && s == "" {
|
if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if slice, ok := actual.([]string); ok && len(slice) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if opLower == "is_present" || opLower == "exists" {
|
||||||
|
if actual == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s, ok := actual.(string); ok && strings.TrimSpace(s) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if slice, ok := actual.([]string); ok && len(slice) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if actual == nil {
|
if actual == nil {
|
||||||
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
|
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Time/Timestamp comparisons
|
// Handle numeric comparisons (word_count >= 500, etc.)
|
||||||
|
if actualNum, ok := toFloat64(actual); ok == nil {
|
||||||
|
if expNum, okExp := toFloat64(expected); okExp == nil {
|
||||||
|
switch opLower {
|
||||||
|
case ">=":
|
||||||
|
return actualNum >= expNum
|
||||||
|
case ">":
|
||||||
|
return actualNum > expNum
|
||||||
|
case "<=":
|
||||||
|
return actualNum <= expNum
|
||||||
|
case "<":
|
||||||
|
return actualNum < expNum
|
||||||
|
case "eq", "==":
|
||||||
|
return actualNum == expNum
|
||||||
|
case "neq", "!=":
|
||||||
|
return actualNum != expNum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Time comparisons
|
||||||
if actualTime, ok := actual.(time.Time); ok {
|
if actualTime, ok := actual.(time.Time); ok {
|
||||||
return e.compareTime(actualTime, opLower, expected)
|
return e.compareTime(actualTime, opLower, expected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle slice containment (e.g. tags contains "Tech")
|
||||||
|
if actualSlice, ok := actual.([]string); ok {
|
||||||
|
expectedStr := fmt.Sprintf("%v", expected)
|
||||||
|
switch opLower {
|
||||||
|
case "contains", "in":
|
||||||
|
for _, item := range actualSlice {
|
||||||
|
if strings.EqualFold(item, expectedStr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case "contains_any":
|
||||||
|
tokens := parseStringList(expected)
|
||||||
|
for _, item := range actualSlice {
|
||||||
|
for _, t := range tokens {
|
||||||
|
if strings.EqualFold(item, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actualStr := fmt.Sprintf("%v", actual)
|
actualStr := fmt.Sprintf("%v", actual)
|
||||||
expectedStr := fmt.Sprintf("%v", expected)
|
expectedStr := fmt.Sprintf("%v", expected)
|
||||||
|
|
||||||
@@ -218,6 +384,16 @@ func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bo
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getTagsSlice(entry *models.FeedEntry) []string {
|
||||||
|
if entry == nil || entry.Metadata == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if raw, ok := entry.Metadata["tags"]; ok {
|
||||||
|
return parseStringList(raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseStringList(val interface{}) []string {
|
func parseStringList(val interface{}) []string {
|
||||||
var result []string
|
var result []string
|
||||||
clean := func(s string) string {
|
clean := func(s string) string {
|
||||||
@@ -279,7 +455,6 @@ func parseTime(val interface{}) (time.Time, error) {
|
|||||||
func ValidateRuleSafety(rule models.Rule) error {
|
func ValidateRuleSafety(rule models.Rule) error {
|
||||||
trigger := strings.ToLower(rule.Trigger)
|
trigger := strings.ToLower(rule.Trigger)
|
||||||
|
|
||||||
// If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key
|
|
||||||
if trigger == "on_variant_updated" || trigger == "variant_change" {
|
if trigger == "on_variant_updated" || trigger == "variant_change" {
|
||||||
for _, action := range rule.Actions {
|
for _, action := range rule.Actions {
|
||||||
if action.Type == "set_variant" {
|
if action.Type == "set_variant" {
|
||||||
@@ -288,7 +463,6 @@ func ValidateRuleSafety(rule models.Rule) error {
|
|||||||
targetKey, _ = action.Params["key"].(string)
|
targetKey, _ = action.Params["key"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if trigger targets this exact key without safe conditions
|
|
||||||
for _, cond := range rule.Conditions {
|
for _, cond := range rule.Conditions {
|
||||||
if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") {
|
if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") {
|
||||||
return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey)
|
return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey)
|
||||||
|
|||||||
@@ -116,3 +116,73 @@ func TestRuleEvaluator(t *testing.T) {
|
|||||||
t.Error("expected ValidateRuleSafety to detect cyclic loop")
|
t.Error("expected ValidateRuleSafety to detect cyclic loop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTransitionEvaluation(t *testing.T) {
|
||||||
|
eval := NewEvaluator()
|
||||||
|
|
||||||
|
oldEntry := models.FeedEntry{
|
||||||
|
ID: "entry-2",
|
||||||
|
FeedID: "feed-1",
|
||||||
|
FeedTitle: "Tech Blog",
|
||||||
|
Title: "Deep Dive into Go Scheduler",
|
||||||
|
Content: "Short intro...",
|
||||||
|
Variants: map[string]models.EntryVariant{},
|
||||||
|
}
|
||||||
|
|
||||||
|
newEntryWithFullText := models.FeedEntry{
|
||||||
|
ID: "entry-2",
|
||||||
|
FeedID: "feed-1",
|
||||||
|
FeedTitle: "Tech Blog",
|
||||||
|
Title: "Deep Dive into Go Scheduler",
|
||||||
|
Content: "Short intro...",
|
||||||
|
Variants: map[string]models.EntryVariant{
|
||||||
|
"readability_full_text": {
|
||||||
|
EntryID: "entry-2",
|
||||||
|
VariantKey: "readability_full_text",
|
||||||
|
TextValue: "Full article content here with extensive details...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := &models.EntryDiff{
|
||||||
|
VariantsAdded: []string{"readability_full_text"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule: trigger AI summary when readability_full_text became_present AND ai_summary is empty
|
||||||
|
aiRule := models.Rule{
|
||||||
|
ID: "r-ai",
|
||||||
|
Name: "AI summary on full text added",
|
||||||
|
MatchMode: "all",
|
||||||
|
Conditions: []models.RuleCondition{
|
||||||
|
{Field: "variants.readability_full_text", Operator: "became_present"},
|
||||||
|
{Field: "variants.ai_summary", Operator: "is_empty"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should match when full text was added
|
||||||
|
if !eval.EvaluateTransition(aiRule, &oldEntry, &newEntryWithFullText, diff) {
|
||||||
|
t.Error("expected aiRule to match when readability_full_text became_present")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should NOT match if tag was added instead of full text
|
||||||
|
diffOnlyTags := &models.EntryDiff{
|
||||||
|
FieldsChanged: []string{"metadata.tags"},
|
||||||
|
}
|
||||||
|
if eval.EvaluateTransition(aiRule, &oldEntry, &oldEntry, diffOnlyTags) {
|
||||||
|
t.Error("expected aiRule to NOT match when only tags changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test word count condition for TTS: word_count >= 5
|
||||||
|
ttsRule := models.Rule{
|
||||||
|
ID: "r-tts",
|
||||||
|
Name: "TTS on long summary",
|
||||||
|
MatchMode: "all",
|
||||||
|
Conditions: []models.RuleCondition{
|
||||||
|
{Field: "variants.readability_full_text.word_count", Operator: ">=", Value: 5},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !eval.EvaluateTransition(ttsRule, &oldEntry, &newEntryWithFullText, diff) {
|
||||||
|
t.Error("expected ttsRule to match when word_count >= 5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntryService struct {
|
||||||
|
db *db.DB
|
||||||
|
bus *events.EventBus
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntryService(database *db.DB, bus *events.EventBus) *EntryService {
|
||||||
|
return &EntryService{
|
||||||
|
db: database,
|
||||||
|
bus: bus,
|
||||||
|
log: logger.Named("entry-service"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IngestEntries handles bulk ingestion of parsed feed entries (from crawler or OPML import)
|
||||||
|
func (s *EntryService) IngestEntries(ctx context.Context, feedID string, entries []models.FeedEntry, feedTitle, feedSiteURL string) ([]models.FeedEntry, error) {
|
||||||
|
newEntries, err := s.db.UpsertFeedEntries(feedID, entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range newEntries {
|
||||||
|
entry.FeedTitle = feedTitle
|
||||||
|
entry.FeedSiteURL = feedSiteURL
|
||||||
|
|
||||||
|
diff := &models.EntryDiff{
|
||||||
|
FieldsChanged: []string{"id", "title", "content", "url"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.bus != nil {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventPostCreated,
|
||||||
|
Entry: entry,
|
||||||
|
NewEntry: &entry,
|
||||||
|
Diff: diff,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventEntryTransition,
|
||||||
|
Entry: entry,
|
||||||
|
NewEntry: &entry,
|
||||||
|
Diff: diff,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchEntry applies atomic mutations to an entry, calculates the EntryDiff between Old and New states,
|
||||||
|
// and fires EventEntryTransition to trigger downstream transition-based automation rules.
|
||||||
|
func (s *EntryService) PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error) {
|
||||||
|
oldEntry, err := s.db.GetEntry(entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("entry not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diff := &models.EntryDiff{
|
||||||
|
FieldsChanged: make([]string, 0),
|
||||||
|
VariantsAdded: make([]string, 0),
|
||||||
|
VariantsChanged: make([]string, 0),
|
||||||
|
VariantsRemoved: make([]string, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Update IsRead
|
||||||
|
if patch.IsRead != nil && *patch.IsRead != oldEntry.IsRead {
|
||||||
|
if err := s.db.UpdateEntryRead(entryID, *patch.IsRead); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
diff.FieldsChanged = append(diff.FieldsChanged, "is_read")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update IsStarred
|
||||||
|
if patch.IsStarred != nil && *patch.IsStarred != oldEntry.IsStarred {
|
||||||
|
if err := s.db.UpdateEntryStarred(entryID, *patch.IsStarred); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
diff.FieldsChanged = append(diff.FieldsChanged, "is_starred")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update Metadata (e.g. tags)
|
||||||
|
if patch.Metadata != nil {
|
||||||
|
if oldEntry.Metadata == nil {
|
||||||
|
oldEntry.Metadata = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
metadataChanged := false
|
||||||
|
for k, v := range patch.Metadata {
|
||||||
|
if !reflect.DeepEqual(oldEntry.Metadata[k], v) {
|
||||||
|
oldEntry.Metadata[k] = v
|
||||||
|
metadataChanged = true
|
||||||
|
diff.FieldsChanged = append(diff.FieldsChanged, "metadata."+k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if metadataChanged {
|
||||||
|
if err := s.db.UpdateEntryMetadata(entryID, oldEntry.Metadata); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update Variants
|
||||||
|
if patch.Variants != nil {
|
||||||
|
for vKey, vVal := range patch.Variants {
|
||||||
|
if vVal == nil {
|
||||||
|
// Variant removal
|
||||||
|
if oldEntry.Variants != nil {
|
||||||
|
if _, exists := oldEntry.Variants[vKey]; exists {
|
||||||
|
_ = s.db.DeleteVariant(entryID, vKey)
|
||||||
|
diff.VariantsRemoved = append(diff.VariantsRemoved, vKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variant add or update
|
||||||
|
vVal.EntryID = entryID
|
||||||
|
vVal.VariantKey = vKey
|
||||||
|
|
||||||
|
oldVariant, exists := oldEntry.Variants[vKey]
|
||||||
|
if !exists {
|
||||||
|
diff.VariantsAdded = append(diff.VariantsAdded, vKey)
|
||||||
|
} else if oldVariant.TextValue != vVal.TextValue || oldVariant.BlobPath != vVal.BlobPath || !reflect.DeepEqual(oldVariant.Metadata, vVal.Metadata) {
|
||||||
|
diff.VariantsChanged = append(diff.VariantsChanged, vKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.SetVariant(*vVal); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to save variant %s: %w", vKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh NewState
|
||||||
|
newEntry, err := s.db.GetEntry(entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to retrieve updated entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Debug("Entry patched with transition diff",
|
||||||
|
zap.String("entry_id", entryID),
|
||||||
|
zap.Strings("fields_changed", diff.FieldsChanged),
|
||||||
|
zap.Strings("variants_added", diff.VariantsAdded),
|
||||||
|
zap.Strings("variants_changed", diff.VariantsChanged),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publish transition event to trigger reactive rules
|
||||||
|
if s.bus != nil {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventEntryTransition,
|
||||||
|
Entry: *newEntry,
|
||||||
|
OldEntry: oldEntry,
|
||||||
|
NewEntry: newEntry,
|
||||||
|
Diff: diff,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventPostUpdated,
|
||||||
|
Entry: *newEntry,
|
||||||
|
OldEntry: oldEntry,
|
||||||
|
NewEntry: newEntry,
|
||||||
|
Diff: diff,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
for _, vKey := range diff.VariantsAdded {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventVariantUpdated,
|
||||||
|
Entry: *newEntry,
|
||||||
|
VariantKey: vKey,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, vKey := range diff.VariantsChanged {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventVariantUpdated,
|
||||||
|
Entry: *newEntry,
|
||||||
|
VariantKey: vKey,
|
||||||
|
Depth: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEntry, diff, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) GetEntry(ctx context.Context, id string) (*models.FeedEntry, error) {
|
||||||
|
return s.db.GetEntry(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) ListEntries(ctx context.Context, filter db.EntryFilter) ([]models.FeedEntry, int, error) {
|
||||||
|
return s.db.ListEntries(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) MarkAllRead(ctx context.Context, feedID, categoryID *string) error {
|
||||||
|
return s.db.MarkAllRead(feedID, categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) ToggleShare(ctx context.Context, id string) (*string, error) {
|
||||||
|
return s.db.ToggleShareEntry(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EntryService) GetSharedEntry(ctx context.Context, shareUUID string) (*models.FeedEntry, error) {
|
||||||
|
return s.db.GetSharedEntry(strings.TrimSuffix(shareUUID, ".html"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEntryServicePatchAndTransition(t *testing.T) {
|
||||||
|
tempDBPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
database, err := db.InitDB(tempDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.Close() }()
|
||||||
|
|
||||||
|
bus := events.NewEventBus()
|
||||||
|
svc := NewEntryService(database, bus)
|
||||||
|
|
||||||
|
// Create test category and feed
|
||||||
|
cat, _ := database.CreateCategory("Tech", nil)
|
||||||
|
feed, _ := database.CreateFeed("Test Feed", "https://example.com/rss", "https://example.com", "", &cat.ID)
|
||||||
|
|
||||||
|
// Ingest an entry
|
||||||
|
entry := models.FeedEntry{
|
||||||
|
ID: "e_test_1",
|
||||||
|
FeedID: feed.ID,
|
||||||
|
GUID: "guid_1",
|
||||||
|
URL: "https://example.com/p/1",
|
||||||
|
Title: "Test Post",
|
||||||
|
Content: "Hello world",
|
||||||
|
PublishedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ingested, err := svc.IngestEntries(context.Background(), feed.ID, []models.FeedEntry{entry}, feed.Title, feed.SiteURL)
|
||||||
|
if err != nil || len(ingested) == 0 {
|
||||||
|
t.Fatalf("failed to ingest entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Test patching tags
|
||||||
|
isStarred := true
|
||||||
|
updated, diff, err := svc.PatchEntry(context.Background(), ingested[0].ID, models.PatchEntryRequest{
|
||||||
|
IsStarred: &isStarred,
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"tags": []string{"AI", "Kubernetes"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to patch entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !updated.IsStarred {
|
||||||
|
t.Errorf("expected entry to be starred")
|
||||||
|
}
|
||||||
|
if !diff.HasFieldChange("is_starred") {
|
||||||
|
t.Errorf("expected diff to contain is_starred change")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test patching variant addition (readability_full_text)
|
||||||
|
fullTextVariant := &models.EntryVariant{
|
||||||
|
ContentType: "html",
|
||||||
|
TextValue: "<p>Full article extracted content...</p>",
|
||||||
|
}
|
||||||
|
|
||||||
|
updated2, diff2, err := svc.PatchEntry(context.Background(), ingested[0].ID, models.PatchEntryRequest{
|
||||||
|
Variants: map[string]*models.EntryVariant{
|
||||||
|
"readability_full_text": fullTextVariant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to patch variant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !diff2.HasVariantAddition("readability_full_text") {
|
||||||
|
t.Errorf("expected diff2 to report variant addition of readability_full_text")
|
||||||
|
}
|
||||||
|
if updated2.Variants["readability_full_text"].TextValue == "" {
|
||||||
|
t.Errorf("expected variant to be loaded in updated entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeedService struct {
|
||||||
|
db *db.DB
|
||||||
|
entryService *EntryService
|
||||||
|
parser *parser.Parser
|
||||||
|
crawler *crawler.Crawler
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeedService(database *db.DB, entrySvc *EntryService, cr *crawler.Crawler) *FeedService {
|
||||||
|
return &FeedService{
|
||||||
|
db: database,
|
||||||
|
entryService: entrySvc,
|
||||||
|
parser: parser.NewParser(),
|
||||||
|
crawler: cr,
|
||||||
|
log: logger.Named("feed-service"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) ListFeeds(ctx context.Context) ([]models.Feed, error) {
|
||||||
|
return s.db.ListFeeds()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) GetFeed(ctx context.Context, id string) (*models.Feed, error) {
|
||||||
|
return s.db.GetFeed(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) CreateFeed(ctx context.Context, req models.CreateFeedRequest) (*models.Feed, error) {
|
||||||
|
feedURL := strings.TrimSpace(req.FeedURL)
|
||||||
|
if feedURL == "" {
|
||||||
|
return nil, fmt.Errorf("feed_url is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, _ := s.db.GetFeedByURL(feedURL); existing != nil {
|
||||||
|
return nil, fmt.Errorf("feed is already subscribed")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.parser.FetchFeed(ctx, feedURL, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch feed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := res.Title
|
||||||
|
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
|
||||||
|
title = strings.TrimSpace(*req.Title)
|
||||||
|
}
|
||||||
|
iconURL := ""
|
||||||
|
if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
|
||||||
|
iconURL = strings.TrimSpace(*req.IconURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := s.db.CreateFeed(title, feedURL, res.SiteURL, iconURL, req.CategoryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res.Entries) > 0 {
|
||||||
|
_, _ = s.entryService.IngestEntries(ctx, feed.ID, res.Entries, feed.Title, feed.SiteURL)
|
||||||
|
_ = s.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.GetFeed(feed.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) UpdateFeed(ctx context.Context, id string, req models.UpdateFeedRequest) (*models.Feed, error) {
|
||||||
|
if err := s.db.UpdateFeed(id, req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.db.GetFeed(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) DeleteFeed(ctx context.Context, id string) error {
|
||||||
|
return s.db.DeleteFeed(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) RefreshFeed(ctx context.Context, id string) (*models.Feed, error) {
|
||||||
|
if s.crawler != nil {
|
||||||
|
if err := s.crawler.RefreshSingle(ctx, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.db.GetFeed(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) RefreshAllFeeds(ctx context.Context) {
|
||||||
|
if s.crawler != nil {
|
||||||
|
go s.crawler.RefreshAll(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category methods
|
||||||
|
func (s *FeedService) ListCategories(ctx context.Context) ([]models.Category, error) {
|
||||||
|
return s.db.ListCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) CreateCategory(ctx context.Context, title string, parentID *string) (*models.Category, error) {
|
||||||
|
return s.db.CreateCategory(strings.TrimSpace(title), parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) DeleteCategory(ctx context.Context, id string) error {
|
||||||
|
return s.db.DeleteCategory(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) GetStats(ctx context.Context) (*models.Stats, error) {
|
||||||
|
return s.db.GetStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPML methods
|
||||||
|
func (s *FeedService) ImportOPML(ctx context.Context, data []byte) (int, int, error) {
|
||||||
|
importedFeeds, err := opml.ParseOPML(data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid OPML XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryCache := make(map[string]string)
|
||||||
|
cats, _ := s.db.ListCategories()
|
||||||
|
for _, c := range cats {
|
||||||
|
categoryCache[c.Title] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
createdCount := 0
|
||||||
|
for _, item := range importedFeeds {
|
||||||
|
if item.FeedURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, _ := s.db.GetFeedByURL(item.FeedURL); existing != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var catID *string
|
||||||
|
if item.CategoryTitle != "" {
|
||||||
|
if cid, ok := categoryCache[item.CategoryTitle]; ok {
|
||||||
|
catID = &cid
|
||||||
|
} else {
|
||||||
|
newCat, err := s.db.CreateCategory(item.CategoryTitle, nil)
|
||||||
|
if err == nil {
|
||||||
|
categoryCache[item.CategoryTitle] = newCat.ID
|
||||||
|
catID = &newCat.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := item.Title
|
||||||
|
if title == "" {
|
||||||
|
title = item.FeedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
|
||||||
|
if err == nil {
|
||||||
|
createdCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.crawler != nil {
|
||||||
|
go s.crawler.RefreshAll(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdCount, len(importedFeeds), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FeedService) ExportOPML(ctx context.Context) ([]byte, error) {
|
||||||
|
feeds, err := s.db.ListFeeds()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cats, err := s.db.ListCategories()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return opml.GenerateOPML(feeds, cats)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobService struct {
|
||||||
|
db *db.DB
|
||||||
|
bus *events.EventBus
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobService(database *db.DB, bus *events.EventBus) *JobService {
|
||||||
|
return &JobService{
|
||||||
|
db: database,
|
||||||
|
bus: bus,
|
||||||
|
log: logger.Named("job-service"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateIdempotencyKey creates a deterministic key to prevent duplicate jobs
|
||||||
|
func GenerateIdempotencyKey(jobType, entityID, action string, extra string) string {
|
||||||
|
raw := fmt.Sprintf("%s:%s:%s:%s", jobType, entityID, action, extra)
|
||||||
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(h[:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue submits a job to the persistent SQLite queue.
|
||||||
|
// If an idempotency key is provided and a job already exists with that key, returns the existing job.
|
||||||
|
func (s *JobService) Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error) {
|
||||||
|
if idempotencyKey != "" {
|
||||||
|
if existing, err := s.db.GetJobByIdempotencyKey(idempotencyKey); err == nil && existing != nil {
|
||||||
|
s.log.Debug("Job already exists with idempotency key, skipping duplicate enqueue",
|
||||||
|
zap.String("job_id", existing.ID),
|
||||||
|
zap.String("type", jobType),
|
||||||
|
zap.String("key", idempotencyKey),
|
||||||
|
)
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job := models.Job{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Type: jobType,
|
||||||
|
Payload: payload,
|
||||||
|
Status: "pending",
|
||||||
|
Progress: 0,
|
||||||
|
Attempts: 0,
|
||||||
|
MaxAttempts: 3,
|
||||||
|
IdempotencyKey: idempotencyKey,
|
||||||
|
RunAt: time.Now().UTC(),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.db.CreateJob(job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to enqueue job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("Job enqueued",
|
||||||
|
zap.String("job_id", created.ID),
|
||||||
|
zap.String("type", created.Type),
|
||||||
|
zap.String("idempotency_key", idempotencyKey),
|
||||||
|
)
|
||||||
|
|
||||||
|
if s.bus != nil {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventJobProgress,
|
||||||
|
Job: created,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) GetJob(ctx context.Context, id string) (*models.Job, error) {
|
||||||
|
return s.db.GetJob(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) ListJobs(ctx context.Context, filter models.JobFilter) ([]models.Job, error) {
|
||||||
|
return s.db.ListJobs(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) CancelJob(ctx context.Context, id string) error {
|
||||||
|
err := s.db.CancelJob(id)
|
||||||
|
if err == nil && s.bus != nil {
|
||||||
|
if j, err := s.db.GetJob(id); err == nil {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventJobFailed,
|
||||||
|
Job: j,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JobService) RetryJob(ctx context.Context, id string) error {
|
||||||
|
j, err := s.db.GetJob(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j.Status = "pending"
|
||||||
|
j.ErrorMessage = ""
|
||||||
|
j.Attempts = 0
|
||||||
|
j.RunAt = time.Now().UTC()
|
||||||
|
err = s.db.UpdateJob(*j)
|
||||||
|
if err == nil && s.bus != nil {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventJobProgress,
|
||||||
|
Job: j,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsService struct {
|
||||||
|
db *db.DB
|
||||||
|
bus *events.EventBus
|
||||||
|
log *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsService(database *db.DB, bus *events.EventBus) *SettingsService {
|
||||||
|
return &SettingsService{
|
||||||
|
db: database,
|
||||||
|
bus: bus,
|
||||||
|
log: logger.Named("settings-service"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) GetPreferences(ctx context.Context, username string) (map[string]interface{}, error) {
|
||||||
|
user, err := s.db.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Preferences == nil {
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
return user.Preferences, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UpdatePreferences(ctx context.Context, username string, newPrefs map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
user, err := s.db.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Preferences == nil {
|
||||||
|
user.Preferences = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
for k, v := range newPrefs {
|
||||||
|
user.Preferences[k] = v
|
||||||
|
}
|
||||||
|
if err := s.db.UpdateUserPreferences(user.ID, user.Preferences); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.bus != nil {
|
||||||
|
s.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventPreferencesSync,
|
||||||
|
DirectAttrs: user.Preferences,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.Preferences, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) GetAISettings(ctx context.Context) (*models.AISettings, error) {
|
||||||
|
raw, err := s.db.GetSetting("ai_settings")
|
||||||
|
if err != nil || raw == "" {
|
||||||
|
return &models.AISettings{
|
||||||
|
Enabled: false,
|
||||||
|
Provider: "openai",
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
APIKey: "",
|
||||||
|
Model: "gpt-4o-mini",
|
||||||
|
Temperature: 0.3,
|
||||||
|
MaxTokens: 1000,
|
||||||
|
Prompts: []models.PromptTemplate{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg models.AISettings
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse AI settings: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UpdateAISettings(ctx context.Context, cfg models.AISettings) (*models.AISettings, error) {
|
||||||
|
bytes, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal AI settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.SetSetting("ai_settings", string(bytes)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobHandlerFunc func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error)
|
||||||
|
|
||||||
|
type EntryPatcher interface {
|
||||||
|
PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error)
|
||||||
|
GetEntry(ctx context.Context, id string) (*models.FeedEntry, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobEngine struct {
|
||||||
|
db *db.DB
|
||||||
|
bus *events.EventBus
|
||||||
|
patcher EntryPatcher
|
||||||
|
handlers map[string]JobHandlerFunc
|
||||||
|
semaphores map[string]chan struct{}
|
||||||
|
maxWorkers int
|
||||||
|
pollTicker *time.Ticker
|
||||||
|
wg sync.WaitGroup
|
||||||
|
log *zap.Logger
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobEngine(database *db.DB, bus *events.EventBus, patcher EntryPatcher, maxWorkers int) *JobEngine {
|
||||||
|
if maxWorkers <= 0 {
|
||||||
|
maxWorkers = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := &JobEngine{
|
||||||
|
db: database,
|
||||||
|
bus: bus,
|
||||||
|
patcher: patcher,
|
||||||
|
handlers: make(map[string]JobHandlerFunc),
|
||||||
|
semaphores: make(map[string]chan struct{}),
|
||||||
|
maxWorkers: maxWorkers,
|
||||||
|
log: logger.Named("job-engine"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default concurrency limits per task type
|
||||||
|
engine.SetConcurrency("ai_summary", 2)
|
||||||
|
engine.SetConcurrency("ai_enrich", 2)
|
||||||
|
engine.SetConcurrency("readability_scrape", 5)
|
||||||
|
engine.SetConcurrency("tts_generate", 2)
|
||||||
|
engine.SetConcurrency("feed_refresh", 10)
|
||||||
|
engine.SetConcurrency("add_tags", 10)
|
||||||
|
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JobEngine) SetConcurrency(jobType string, limit int) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 2
|
||||||
|
}
|
||||||
|
e.semaphores[jobType] = make(chan struct{}, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JobEngine) RegisterHandler(jobType string, handler JobHandlerFunc) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
e.handlers[jobType] = handler
|
||||||
|
if _, ok := e.semaphores[jobType]; !ok {
|
||||||
|
e.semaphores[jobType] = make(chan struct{}, 5)
|
||||||
|
}
|
||||||
|
e.log.Info("Registered job handler", zap.String("type", jobType))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JobEngine) Start(ctx context.Context) {
|
||||||
|
e.log.Info("Starting persistent SQLite job queue engine")
|
||||||
|
|
||||||
|
// 1. Recover crashed/orphaned jobs on boot
|
||||||
|
if err := e.db.ResetRunningJobsToPending(); err != nil {
|
||||||
|
e.log.Error("Failed to reset orphaned running jobs", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Start dispatcher loop
|
||||||
|
e.pollTicker = time.NewTicker(500 * time.Millisecond)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-e.pollTicker.C:
|
||||||
|
e.dispatchPendingJobs(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JobEngine) Stop() {
|
||||||
|
if e.pollTicker != nil {
|
||||||
|
e.pollTicker.Stop()
|
||||||
|
}
|
||||||
|
e.wg.Wait()
|
||||||
|
e.log.Info("Job engine stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JobEngine) dispatchPendingJobs(ctx context.Context) {
|
||||||
|
e.mu.RLock()
|
||||||
|
var registeredTypes []string
|
||||||
|
for t := range e.handlers {
|
||||||
|
registeredTypes = append(registeredTypes, t)
|
||||||
|
}
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(registeredTypes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim next due job
|
||||||
|
job, err := e.db.ClaimNextJob(registeredTypes)
|
||||||
|
if err != nil || job == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire concurrency slot for this job type
|
||||||
|
e.mu.RLock()
|
||||||
|
sem, hasSem := e.semaphores[job.Type]
|
||||||
|
handler, hasHandler := e.handlers[job.Type]
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
if !hasHandler {
|
||||||
|
e.log.Warn("No handler registered for claimed job", zap.String("job_id", job.ID), zap.String("type", job.Type))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasSem {
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
// Slot acquired
|
||||||
|
default:
|
||||||
|
// Concurrency limit reached for this type, release job back to pending
|
||||||
|
job.Status = "pending"
|
||||||
|
_ = e.db.UpdateJob(*job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.wg.Add(1)
|
||||||
|
go func(j models.Job, h JobHandlerFunc, s chan struct{}) {
|
||||||
|
defer e.wg.Done()
|
||||||
|
if s != nil {
|
||||||
|
defer func() { <-s }()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.executeJob(ctx, j, h)
|
||||||
|
}(*job, handler, sem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *JobEngine) executeJob(ctx context.Context, job models.Job, handler JobHandlerFunc) {
|
||||||
|
e.log.Info("Executing job",
|
||||||
|
zap.String("job_id", job.ID),
|
||||||
|
zap.String("type", job.Type),
|
||||||
|
zap.Int("attempt", job.Attempts+1),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emit Job Progress
|
||||||
|
if e.bus != nil {
|
||||||
|
e.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventJobProgress,
|
||||||
|
Job: &job,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
patchReq, result, err := handler(ctx, job)
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
job.Attempts++
|
||||||
|
e.log.Error("Job execution failed",
|
||||||
|
zap.String("job_id", job.ID),
|
||||||
|
zap.String("type", job.Type),
|
||||||
|
zap.Int("attempts", job.Attempts),
|
||||||
|
zap.Duration("duration", duration),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
|
||||||
|
if job.Attempts < job.MaxAttempts {
|
||||||
|
// Exponential backoff retry: 5s, 10s, 20s...
|
||||||
|
backoff := time.Duration(1<<job.Attempts) * 5 * time.Second
|
||||||
|
job.Status = "pending"
|
||||||
|
job.ErrorMessage = err.Error()
|
||||||
|
job.RunAt = time.Now().UTC().Add(backoff)
|
||||||
|
_ = e.db.UpdateJob(job)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent failure
|
||||||
|
job.Status = "failed"
|
||||||
|
job.ErrorMessage = err.Error()
|
||||||
|
job.Progress = 100
|
||||||
|
_ = e.db.UpdateJob(job)
|
||||||
|
|
||||||
|
if e.bus != nil {
|
||||||
|
e.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventJobFailed,
|
||||||
|
Job: &job,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Patch to Entry if returned
|
||||||
|
entryID, _ := job.Payload["entry_id"].(string)
|
||||||
|
if patchReq != nil && entryID != "" && e.patcher != nil {
|
||||||
|
_, _, patchErr := e.patcher.PatchEntry(ctx, entryID, *patchReq)
|
||||||
|
if patchErr != nil {
|
||||||
|
e.log.Error("Failed to patch entry from job result",
|
||||||
|
zap.String("job_id", job.ID),
|
||||||
|
zap.String("entry_id", entryID),
|
||||||
|
zap.Error(patchErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark Job Completed
|
||||||
|
job.Status = "completed"
|
||||||
|
job.Progress = 100
|
||||||
|
job.Result = result
|
||||||
|
job.ErrorMessage = ""
|
||||||
|
_ = e.db.UpdateJob(job)
|
||||||
|
|
||||||
|
e.log.Info("Job completed successfully",
|
||||||
|
zap.String("job_id", job.ID),
|
||||||
|
zap.String("type", job.Type),
|
||||||
|
zap.Duration("duration", duration),
|
||||||
|
)
|
||||||
|
|
||||||
|
if e.bus != nil {
|
||||||
|
e.bus.Publish(ctx, events.Event{
|
||||||
|
Type: events.EventJobCompleted,
|
||||||
|
Job: &job,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteDirect executes a registered action synchronously (e.g. for fallback or test)
|
||||||
|
func (e *JobEngine) ExecuteDirect(ctx context.Context, jobType string, payload map[string]interface{}) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
e.mu.RLock()
|
||||||
|
handler, ok := e.handlers[jobType]
|
||||||
|
e.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("unknown job type: %s", jobType)
|
||||||
|
}
|
||||||
|
|
||||||
|
job := models.Job{
|
||||||
|
ID: "direct_exec",
|
||||||
|
Type: jobType,
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(ctx, job)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||||
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockPatcher struct {
|
||||||
|
patchedCount int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPatcher) PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error) {
|
||||||
|
atomic.AddInt32(&m.patchedCount, 1)
|
||||||
|
return &models.FeedEntry{ID: entryID}, &models.EntryDiff{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPatcher) GetEntry(ctx context.Context, id string) (*models.FeedEntry, error) {
|
||||||
|
return &models.FeedEntry{ID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobEngineExecution(t *testing.T) {
|
||||||
|
tempDBPath := filepath.Join(t.TempDir(), "job_test.db")
|
||||||
|
database, err := db.InitDB(tempDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to init db: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.Close() }()
|
||||||
|
|
||||||
|
bus := events.NewEventBus()
|
||||||
|
patcher := &mockPatcher{}
|
||||||
|
engine := NewJobEngine(database, bus, patcher, 5)
|
||||||
|
|
||||||
|
var executedCount int32
|
||||||
|
engine.RegisterHandler("test_task", func(ctx context.Context, job models.Job) (*models.PatchEntryRequest, map[string]interface{}, error) {
|
||||||
|
atomic.AddInt32(&executedCount, 1)
|
||||||
|
read := true
|
||||||
|
return &models.PatchEntryRequest{IsRead: &read}, map[string]interface{}{"done": true}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
engine.Start(ctx)
|
||||||
|
|
||||||
|
// Enqueue test job
|
||||||
|
job := models.Job{
|
||||||
|
Type: "test_task",
|
||||||
|
Payload: map[string]interface{}{"entry_id": "e_100"},
|
||||||
|
Status: "pending",
|
||||||
|
IdempotencyKey: "test_key_1",
|
||||||
|
RunAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
created, err := database.CreateJob(job)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for engine to claim and execute
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
finalJob, err := database.GetJob(created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get job: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalJob.Status != "completed" {
|
||||||
|
t.Errorf("expected job status 'completed', got '%s'", finalJob.Status)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&executedCount) != 1 {
|
||||||
|
t.Errorf("expected executedCount 1, got %d", atomic.LoadInt32(&executedCount))
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&patcher.patchedCount) != 1 {
|
||||||
|
t.Errorf("expected patcher to be called once, got %d", atomic.LoadInt32(&patcher.patchedCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.Stop()
|
||||||
|
_ = database.Close()
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<meta name="description" content="RSSPipe - RSS Driven Information Gateway" />
|
<meta name="description" content="RSSPipe - RSS Driven Information Gateway" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|||||||
+374
-305
@@ -1,20 +1,20 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { api } from './services/api';
|
import { api } from './services/api';
|
||||||
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode } from './types';
|
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode, ViewPreference } from './types';
|
||||||
import { LoginView } from './components/LoginView';
|
import { LoginView } from './components/common/LoginView';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/common/Header';
|
||||||
import { ThreePaneView } from './components/ThreePaneView';
|
import { ThreePaneView } from './components/desktop/ThreePaneView';
|
||||||
import { WaterfallView } from './components/WaterfallView';
|
import { WaterfallView } from './components/mobile/WaterfallView';
|
||||||
import { SettingsView } from './components/SettingsView';
|
import { SettingsView } from './components/settings/SettingsView';
|
||||||
import { AddFeedModal } from './components/AddFeedModal';
|
import { AddFeedModal } from './components/common/AddFeedModal';
|
||||||
import { OPMLModal } from './components/OPMLModal';
|
import { OPMLModal } from './components/common/OPMLModal';
|
||||||
import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal';
|
import { KeyboardShortcutsModal } from './components/common/KeyboardShortcutsModal';
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
|
|
||||||
// App State with localStorage Persistence
|
// App Filter State with Persistence
|
||||||
const [filter, setFilter] = useState<FilterState>(() => {
|
const [filter, setFilter] = useState<FilterState>(() => {
|
||||||
const saved = localStorage.getItem('rsspipe_filter');
|
const saved = localStorage.getItem('rsspipe_filter');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -27,49 +27,82 @@ export const App: React.FC = () => {
|
|||||||
return { view: 'inbox', search: '' };
|
return { view: 'inbox', search: '' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
// View Preference: 'auto' | 'mobile' | 'desktop'
|
||||||
const saved = localStorage.getItem('rsspipe_view_mode') as ViewMode | null;
|
const [viewPreference, setViewPreference] = useState<ViewPreference>(() => {
|
||||||
if (saved && (saved === 'split' || saved === 'waterfall' || saved === 'settings')) {
|
const saved = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null;
|
||||||
return saved;
|
return saved && (saved === 'auto' || saved === 'mobile' || saved === 'desktop') ? saved : 'auto';
|
||||||
}
|
|
||||||
return window.innerWidth < 768 ? 'waterfall' : 'split';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
// Active View Mode: 'split' | 'waterfall' | 'settings'
|
||||||
return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true';
|
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||||
|
const savedPref = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null;
|
||||||
|
if (savedPref === 'mobile') return 'waterfall';
|
||||||
|
if (savedPref === 'desktop') return 'split';
|
||||||
|
const isNarrow = typeof window !== 'undefined' ? window.innerWidth < 768 : false;
|
||||||
|
return isNarrow ? 'waterfall' : 'split';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [lastContentMode, setLastContentMode] = useState<'split' | 'waterfall'>('split');
|
||||||
|
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(() => {
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(() => {
|
||||||
return localStorage.getItem('rsspipe_selected_entry_id');
|
return localStorage.getItem('rsspipe_selected_entry_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
const [activeArticle, setActiveArticle] = useState<FeedEntry | null>(null);
|
|
||||||
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||||
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
||||||
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [showAddFeedModal, setShowAddFeedModal] = useState(false);
|
const [showAddFeedModal, setShowAddFeedModal] = useState(false);
|
||||||
const [showOPMLModal, setShowOPMLModal] = useState(false);
|
const [showOPMLModal, setShowOPMLModal] = useState(false);
|
||||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||||
|
|
||||||
// Persist state updates to localStorage
|
// Global Audio Engine State
|
||||||
|
const [currentAudioUrl, setCurrentAudioUrl] = useState<string | null>(null);
|
||||||
|
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
||||||
|
const [playingEntry, setPlayingEntry] = useState<FeedEntry | null>(null);
|
||||||
|
|
||||||
|
// Persist filter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('rsspipe_filter', JSON.stringify(filter));
|
localStorage.setItem('rsspipe_filter', JSON.stringify(filter));
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
|
|
||||||
|
// Persist view preference & synchronize active view layout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('rsspipe_view_mode', viewMode);
|
localStorage.setItem('rsspipe_view_preference', viewPreference);
|
||||||
|
|
||||||
|
const handleAutoResize = () => {
|
||||||
|
if (viewMode === 'settings') return;
|
||||||
|
|
||||||
|
if (viewPreference === 'mobile') {
|
||||||
|
setViewMode('waterfall');
|
||||||
|
} else if (viewPreference === 'desktop') {
|
||||||
|
setViewMode('split');
|
||||||
|
} else {
|
||||||
|
const isNarrow = window.innerWidth < 768;
|
||||||
|
setViewMode(isNarrow ? 'waterfall' : 'split');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAutoResize();
|
||||||
|
window.addEventListener('resize', handleAutoResize);
|
||||||
|
return () => window.removeEventListener('resize', handleAutoResize);
|
||||||
|
}, [viewPreference, viewMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewMode === 'split' || viewMode === 'waterfall') {
|
||||||
|
setLastContentMode(viewMode);
|
||||||
|
}
|
||||||
}, [viewMode]);
|
}, [viewMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('rsspipe_sidebar_collapsed', sidebarCollapsed ? 'true' : 'false');
|
if (selectedEntryId) {
|
||||||
}, [sidebarCollapsed]);
|
localStorage.setItem('rsspipe_selected_entry_id', selectedEntryId);
|
||||||
|
}
|
||||||
|
}, [selectedEntryId]);
|
||||||
|
|
||||||
// Check auth session
|
// Check auth session on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const token = localStorage.getItem('rsspipe_jwt');
|
const token = localStorage.getItem('rsspipe_jwt');
|
||||||
@@ -80,6 +113,15 @@ export const App: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const user = await api.getMe();
|
const user = await api.getMe();
|
||||||
setCurrentUser(user);
|
setCurrentUser(user);
|
||||||
|
if (user.preferences) {
|
||||||
|
if (user.preferences.auto_read !== undefined) {
|
||||||
|
localStorage.setItem('rsspipe_auto_read_scroll', user.preferences.auto_read ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (user.preferences.view_preference) {
|
||||||
|
setViewPreference(user.preferences.view_preference);
|
||||||
|
localStorage.setItem('rsspipe_view_preference', user.preferences.view_preference);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('rsspipe_jwt');
|
localStorage.removeItem('rsspipe_jwt');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,10 +134,40 @@ export const App: React.FC = () => {
|
|||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
};
|
};
|
||||||
window.addEventListener('auth:unauthorized', handleUnauthorized);
|
window.addEventListener('auth:unauthorized', handleUnauthorized);
|
||||||
return () => window.removeEventListener('auth:unauthorized', handleUnauthorized);
|
|
||||||
|
const syncPreferences = async () => {
|
||||||
|
const token = localStorage.getItem('rsspipe_jwt');
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const prefs = await api.getUserPreferences();
|
||||||
|
if (prefs) {
|
||||||
|
if (prefs.auto_read !== undefined) {
|
||||||
|
localStorage.setItem('rsspipe_auto_read_scroll', prefs.auto_read ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (prefs.view_preference) {
|
||||||
|
setViewPreference(prefs.view_preference);
|
||||||
|
localStorage.setItem('rsspipe_view_preference', prefs.view_preference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', syncPreferences);
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') syncPreferences();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('auth:unauthorized', handleUnauthorized);
|
||||||
|
window.removeEventListener('focus', syncPreferences);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load sidebar data (Feeds, Categories, Stats)
|
// Load sidebar data (Categories, Feeds, Stats)
|
||||||
const loadSidebarData = useCallback(async () => {
|
const loadSidebarData = useCallback(async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
try {
|
try {
|
||||||
@@ -112,24 +184,63 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Load entries matching current filter
|
const [totalEntries, setTotalEntries] = useState(0);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
// Load entries matching filter
|
||||||
const loadEntries = useCallback(async () => {
|
const loadEntries = useCallback(async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
try {
|
try {
|
||||||
const res = await api.listEntries({
|
const res = await api.listEntries({
|
||||||
view: filter.view,
|
view: filter.view,
|
||||||
category_id: filter.categoryId,
|
|
||||||
feed_id: filter.feedId,
|
feed_id: filter.feedId,
|
||||||
search: filter.search,
|
category_id: filter.categoryId,
|
||||||
limit: 100,
|
search: filter.search || undefined,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
});
|
});
|
||||||
setEntries(res.entries);
|
setEntries(res.entries || []);
|
||||||
|
setTotalEntries(res.total || 0);
|
||||||
|
|
||||||
|
if (res.entries && res.entries.length > 0) {
|
||||||
|
setSelectedEntryId((prev) => {
|
||||||
|
if (prev && res.entries.some((e) => e.id === prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return res.entries[0].id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSelectedEntryId(null);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load entries:', err);
|
console.error('Failed to load entries:', err);
|
||||||
}
|
}
|
||||||
}, [currentUser, filter]);
|
}, [currentUser, filter]);
|
||||||
|
|
||||||
// Initial load
|
// Load more entries for infinite scrolling
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
if (isLoadingMore || entries.length >= totalEntries || !currentUser) return;
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const res = await api.listEntries({
|
||||||
|
view: filter.view,
|
||||||
|
feed_id: filter.feedId,
|
||||||
|
category_id: filter.categoryId,
|
||||||
|
search: filter.search || undefined,
|
||||||
|
limit: 50,
|
||||||
|
offset: entries.length,
|
||||||
|
});
|
||||||
|
if (res.entries && res.entries.length > 0) {
|
||||||
|
setEntries((prev) => [...prev, ...res.entries]);
|
||||||
|
}
|
||||||
|
setTotalEntries(res.total || 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load more entries:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
@@ -137,166 +248,157 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentUser, loadSidebarData, loadEntries]);
|
}, [currentUser, loadSidebarData, loadEntries]);
|
||||||
|
|
||||||
// Sync focused index & active article when entries change without disrupting reading state
|
// Global Refresh Action with guaranteed animation feedback duration
|
||||||
useEffect(() => {
|
const handleRefreshAll = async () => {
|
||||||
if (entries.length > 0) {
|
if (isRefreshing) return;
|
||||||
if (activeArticle) {
|
setIsRefreshing(true);
|
||||||
const found = entries.find((e) => e.id === activeArticle.id);
|
const minDelay = new Promise((resolve) => setTimeout(resolve, 600));
|
||||||
if (found) {
|
try {
|
||||||
setActiveArticle(found);
|
await Promise.all([
|
||||||
setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id));
|
api.refreshAllFeeds(),
|
||||||
return;
|
minDelay,
|
||||||
}
|
]);
|
||||||
// Active article remains in reader canvas from prior feed, but list is unselected
|
await Promise.all([loadSidebarData(), loadEntries()]);
|
||||||
setFocusedIndex(-1);
|
} catch (err) {
|
||||||
return;
|
console.error('Refresh all failed:', err);
|
||||||
}
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
if (selectedEntryId) {
|
|
||||||
const found = entries.find((e) => e.id === selectedEntryId);
|
|
||||||
if (found) {
|
|
||||||
setActiveArticle(found);
|
|
||||||
setFocusedIndex(entries.findIndex((e) => e.id === selectedEntryId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If not found in current list, load directly so reading pane stays populated
|
|
||||||
api.getEntry(selectedEntryId).then((entry) => {
|
|
||||||
setActiveArticle(entry);
|
|
||||||
}).catch(() => {
|
|
||||||
// fallback to top
|
|
||||||
if (entries[0]) {
|
|
||||||
setFocusedIndex(0);
|
|
||||||
setActiveArticle(entries[0]);
|
|
||||||
setSelectedEntryId(entries[0].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocusedIndex(0);
|
|
||||||
setActiveArticle(entries[0]);
|
|
||||||
setSelectedEntryId(entries[0].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
|
||||||
}
|
|
||||||
}, [entries]);
|
|
||||||
|
|
||||||
const handleSelectEntry = (index: number) => {
|
|
||||||
setFocusedIndex(index);
|
|
||||||
if (entries[index]) {
|
|
||||||
setActiveArticle(entries[index]);
|
|
||||||
setSelectedEntryId(entries[index].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Refresh data only on initial mount or when user clicks refresh / changes filter
|
// Toggle Read Action
|
||||||
|
const handleToggleRead = async (entry: FeedEntry) => {
|
||||||
// Toggle Read Action with optimistic update
|
const nextRead = !entry.is_read;
|
||||||
const handleToggleRead = async (entry: FeedEntry, e?: React.MouseEvent) => {
|
|
||||||
if (e) e.stopPropagation();
|
|
||||||
const newStatus = !entry.is_read;
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
setEntries((prev) =>
|
setEntries((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((e) => (e.id === entry.id ? { ...e, is_read: nextRead } : e))
|
||||||
item.id === entry.id ? { ...item, is_read: newStatus } : item
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
if (activeArticle && activeArticle.id === entry.id) {
|
|
||||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateRead(entry.id, newStatus);
|
await api.updateRead(entry.id, nextRead);
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update read status:', err);
|
console.error('Update read failed:', err);
|
||||||
// Revert on error
|
|
||||||
setEntries((prev) =>
|
|
||||||
prev.map((item) =>
|
|
||||||
item.id === entry.id ? { ...item, is_read: !newStatus } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (activeArticle && activeArticle.id === entry.id) {
|
|
||||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: !newStatus } : prev));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle Star Action with optimistic update
|
// Toggle Star Action
|
||||||
const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => {
|
const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => {
|
||||||
if (e) e.stopPropagation();
|
if (e) e.stopPropagation();
|
||||||
const newStatus = !entry.is_starred;
|
const nextStar = !entry.is_starred;
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
setEntries((prev) =>
|
setEntries((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((e) => (e.id === entry.id ? { ...e, is_starred: nextStar } : e))
|
||||||
item.id === entry.id ? { ...item, is_starred: newStatus } : item
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
if (activeArticle && activeArticle.id === entry.id) {
|
|
||||||
setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateStar(entry.id, newStatus);
|
await api.updateStar(entry.id, nextStar);
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update star status:', err);
|
console.error('Update star failed:', err);
|
||||||
setEntries((prev) =>
|
|
||||||
prev.map((item) =>
|
|
||||||
item.id === entry.id ? { ...item, is_starred: !newStatus } : item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (activeArticle && activeArticle.id === entry.id) {
|
|
||||||
setActiveArticle((prev) => (prev ? { ...prev, is_starred: !newStatus } : prev));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mark All Read
|
// Mark All As Read Action
|
||||||
const handleMarkAllRead = async () => {
|
const handleMarkAllRead = async () => {
|
||||||
try {
|
try {
|
||||||
await api.markAllRead(filter.feedId, filter.categoryId);
|
await api.markAllRead(filter.feedId, filter.categoryId);
|
||||||
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
|
setEntries((prev) => prev.map((e) => ({ ...e, is_read: true })));
|
||||||
if (activeArticle) {
|
|
||||||
setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev));
|
|
||||||
}
|
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to mark all read:', err);
|
console.error('Mark all read failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Refresh All Feeds
|
// Connect Real-Time SSE Stream for live state sync across tabs and async background jobs
|
||||||
const handleRefreshAll = async () => {
|
useEffect(() => {
|
||||||
setIsRefreshing(true);
|
if (!currentUser) return;
|
||||||
try {
|
|
||||||
await api.refreshAllFeeds();
|
const disconnect = api.connectEventStream((eventType, data) => {
|
||||||
setTimeout(() => {
|
if (eventType === 'job_completed' || eventType === 'post_updated' || eventType === 'variant_updated') {
|
||||||
|
const updatedEntry = data?.entry || data;
|
||||||
|
if (updatedEntry && updatedEntry.id) {
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.id === updatedEntry.id
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
...updatedEntry,
|
||||||
|
variants: { ...(e.variants || {}), ...(updatedEntry.variants || {}) },
|
||||||
|
}
|
||||||
|
: e
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'post_created') {
|
||||||
loadSidebarData();
|
loadSidebarData();
|
||||||
loadEntries();
|
} else if (eventType === 'preferences_updated') {
|
||||||
setIsRefreshing(false);
|
if (data && typeof data === 'object') {
|
||||||
}, 2500);
|
if (data.auto_read !== undefined) {
|
||||||
} catch (err) {
|
localStorage.setItem('rsspipe_auto_read_scroll', data.auto_read ? 'true' : 'false');
|
||||||
console.error('Failed to refresh feeds:', err);
|
}
|
||||||
setIsRefreshing(false);
|
if (data.view_preference) {
|
||||||
|
setViewPreference(data.view_preference);
|
||||||
|
localStorage.setItem('rsspipe_view_preference', data.view_preference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => disconnect();
|
||||||
|
}, [currentUser, loadSidebarData]);
|
||||||
|
|
||||||
|
// Update Single Entry Action
|
||||||
|
const handleUpdateEntry = (updated: FeedEntry) => {
|
||||||
|
setEntries((prev) =>
|
||||||
|
prev.map((e) =>
|
||||||
|
e.id === updated.id
|
||||||
|
? {
|
||||||
|
...e,
|
||||||
|
...updated,
|
||||||
|
variants: { ...(e.variants || {}), ...(updated.variants || {}) },
|
||||||
|
}
|
||||||
|
: e
|
||||||
|
)
|
||||||
|
);
|
||||||
|
loadSidebarData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audio Playback Actions
|
||||||
|
const handlePlayAudio = (entry: FeedEntry, streamUrl: string) => {
|
||||||
|
if (currentAudioUrl === streamUrl) {
|
||||||
|
setIsPlayingAudio(!isPlayingAudio);
|
||||||
|
} else {
|
||||||
|
setCurrentAudioUrl(streamUrl);
|
||||||
|
setPlayingEntry(entry);
|
||||||
|
setIsPlayingAudio(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePlayAudio = () => {
|
||||||
|
setIsPlayingAudio(!isPlayingAudio);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseAudio = () => {
|
||||||
|
setIsPlayingAudio(false);
|
||||||
|
setCurrentAudioUrl(null);
|
||||||
|
setPlayingEntry(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// View Preference Change Handler
|
||||||
|
const handleChangeViewPreference = (pref: ViewPreference) => {
|
||||||
|
setViewPreference(pref);
|
||||||
|
if (pref === 'desktop') {
|
||||||
|
setViewMode('split');
|
||||||
|
} else if (pref === 'mobile') {
|
||||||
|
setViewMode('waterfall');
|
||||||
|
} else {
|
||||||
|
const isNarrow = window.innerWidth < 768;
|
||||||
|
setViewMode(isNarrow ? 'waterfall' : 'split');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global Keyboard Shortcuts
|
// Global Keyboard Shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Don't intercept inside input fields
|
const tag = document.activeElement?.tagName;
|
||||||
if (
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||||
document.activeElement?.tagName === 'INPUT' ||
|
|
||||||
document.activeElement?.tagName === 'TEXTAREA' ||
|
|
||||||
document.activeElement?.tagName === 'SELECT'
|
|
||||||
) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
(document.activeElement as HTMLElement).blur();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,157 +406,99 @@ export const App: React.FC = () => {
|
|||||||
setShowAddFeedModal(false);
|
setShowAddFeedModal(false);
|
||||||
setShowOPMLModal(false);
|
setShowOPMLModal(false);
|
||||||
setShowShortcutsModal(false);
|
setShowShortcutsModal(false);
|
||||||
|
if (viewMode === 'settings') {
|
||||||
|
setViewMode(lastContentMode);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (e.key) {
|
if (e.key === '?' || (e.shiftKey && e.key === '/')) {
|
||||||
case '[': // Toggle Sidebar
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSidebarCollapsed((prev) => !prev);
|
setShowShortcutsModal(true);
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case 'j':
|
if (e.key === '1') {
|
||||||
case 'J': // Next item
|
setViewMode('split');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === '2') {
|
||||||
|
setViewMode('waterfall');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === '3') {
|
||||||
|
setViewMode('settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'r' || e.key === 'R') {
|
||||||
|
if (!e.metaKey && !e.ctrlKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (entries.length > 0) {
|
handleRefreshAll();
|
||||||
setFocusedIndex((prev) => {
|
return;
|
||||||
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
|
|
||||||
if (entries[next]) {
|
|
||||||
setActiveArticle(entries[next]);
|
|
||||||
setSelectedEntryId(entries[next].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
|
||||||
}
|
}
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
|
|
||||||
case 'k':
|
if (e.key === 'a' || e.key === 'A') {
|
||||||
case 'K': // Previous item
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (entries.length > 0) {
|
handleMarkAllRead();
|
||||||
setFocusedIndex((prev) => {
|
} else if (!e.metaKey && !e.ctrlKey) {
|
||||||
const next = prev < 0 ? 0 : Math.max(prev - 1, 0);
|
|
||||||
if (entries[next]) {
|
|
||||||
setActiveArticle(entries[next]);
|
|
||||||
setSelectedEntryId(entries[next].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'v':
|
|
||||||
case 'V': // Open original link
|
|
||||||
{
|
|
||||||
const targetUrl = activeArticle?.url || entries[focusedIndex]?.url;
|
|
||||||
if (targetUrl) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.open(targetUrl, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 's':
|
|
||||||
case 'S': // Toggle Star
|
|
||||||
{
|
|
||||||
const target = activeArticle || entries[focusedIndex];
|
|
||||||
if (target) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleToggleStar(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'm':
|
|
||||||
case 'M': // Toggle Read / Unread
|
|
||||||
{
|
|
||||||
const target = activeArticle || entries[focusedIndex];
|
|
||||||
if (target) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleToggleRead(target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'e':
|
|
||||||
case 'E': // Archive / Mark read and advance
|
|
||||||
{
|
|
||||||
const target = activeArticle || entries[focusedIndex];
|
|
||||||
if (target) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!target.is_read) {
|
|
||||||
handleToggleRead(target);
|
|
||||||
}
|
|
||||||
if (entries.length > 0) {
|
|
||||||
setFocusedIndex((prev) => {
|
|
||||||
const next = prev < 0 ? 0 : Math.min(prev + 1, entries.length - 1);
|
|
||||||
if (entries[next]) {
|
|
||||||
setActiveArticle(entries[next]);
|
|
||||||
setSelectedEntryId(entries[next].id);
|
|
||||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'a':
|
|
||||||
case 'A': // Add feed modal
|
|
||||||
if (!e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowAddFeedModal(true);
|
setShowAddFeedModal(true);
|
||||||
}
|
}
|
||||||
break;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
case 'r':
|
// 3-Split specific shortcuts
|
||||||
case 'R': // Refresh feeds
|
if (viewMode === 'split' && entries.length > 0) {
|
||||||
e.preventDefault();
|
const currentIndex = entries.findIndex((item) => item.id === selectedEntryId);
|
||||||
handleRefreshAll();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case '1': // Switch to Desktop 3-Split View
|
if (e.key === 'j' || e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setViewMode('split');
|
const nextIndex = Math.min(currentIndex + 1, entries.length - 1);
|
||||||
break;
|
if (entries[nextIndex]) {
|
||||||
|
const nextEntry = entries[nextIndex];
|
||||||
case '2': // Switch to Xiaohongshu Waterfall View
|
setSelectedEntryId(nextEntry.id);
|
||||||
|
if (!nextEntry.is_read) {
|
||||||
|
handleToggleRead(nextEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 'k' || e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setViewMode('waterfall');
|
const prevIndex = Math.max(currentIndex - 1, 0);
|
||||||
break;
|
if (entries[prevIndex]) {
|
||||||
|
const prevEntry = entries[prevIndex];
|
||||||
case '3': // Switch to Settings View
|
setSelectedEntryId(prevEntry.id);
|
||||||
|
if (!prevEntry.is_read) {
|
||||||
|
handleToggleRead(prevEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 's') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setViewMode('settings');
|
const currentEntry = entries[currentIndex];
|
||||||
break;
|
if (currentEntry) handleToggleStar(currentEntry);
|
||||||
|
} else if (e.key === 'm') {
|
||||||
case '?': // Shortcuts modal
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowShortcutsModal(true);
|
const currentEntry = entries[currentIndex];
|
||||||
break;
|
if (currentEntry) handleToggleRead(currentEntry);
|
||||||
|
} else if (e.key === 'o') {
|
||||||
default:
|
const currentEntry = entries[currentIndex];
|
||||||
if (e.shiftKey && e.key === 'A') {
|
if (currentEntry?.url) {
|
||||||
e.preventDefault();
|
window.open(currentEntry.url, '_blank');
|
||||||
if (window.confirm('Mark all items in this view as read?')) {
|
|
||||||
handleMarkAllRead();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [entries, focusedIndex, activeArticle]);
|
}, [viewMode, entries, selectedEntryId, isRefreshing, lastContentMode]);
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="login-container">
|
<div style={{ display: 'flex', height: '100vh', width: '100vw', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-app)' }}>
|
||||||
<div style={{ color: 'var(--text-main)', fontSize: '1.1rem', fontWeight: 600 }}>
|
<div style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||||
Loading RSSPipe...
|
Loading RSSPipe...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -462,62 +506,84 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return <LoginView onLoginSuccess={(user) => setCurrentUser(user)} />;
|
return <LoginView onLoginSuccess={(u) => setCurrentUser(u)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
{/* App Header */}
|
{/* Top Header is rendered in 3-Split desktop view */}
|
||||||
|
{viewMode === 'split' && (
|
||||||
<Header
|
<Header
|
||||||
search={filter.search}
|
search={filter.search}
|
||||||
onSearchChange={(val) => setFilter((prev) => ({ ...prev, search: val }))}
|
onSearchChange={(search) => setFilter((prev) => ({ ...prev, search }))}
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
onRefreshAll={handleRefreshAll}
|
onRefreshAll={handleRefreshAll}
|
||||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||||
|
onOpenSettings={() => setViewMode('settings')}
|
||||||
onOpenShortcuts={() => setShowShortcutsModal(true)}
|
onOpenShortcuts={() => setShowShortcutsModal(true)}
|
||||||
onLogout={async () => {
|
onLogout={async () => {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
}}
|
}}
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
|
||||||
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main View Mode Content */}
|
{/* Main View Router */}
|
||||||
{viewMode === 'split' ? (
|
{viewMode === 'split' ? (
|
||||||
<ThreePaneView
|
<ThreePaneView
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
feeds={feeds}
|
feeds={feeds}
|
||||||
stats={stats}
|
|
||||||
entries={entries}
|
entries={entries}
|
||||||
selectedIndex={focusedIndex}
|
selectedEntryId={selectedEntryId}
|
||||||
activeArticle={activeArticle}
|
onSelectEntry={(entry) => {
|
||||||
onSelectEntry={handleSelectEntry}
|
setSelectedEntryId(entry.id);
|
||||||
onToggleRead={handleToggleRead}
|
if (!entry.is_read) {
|
||||||
onToggleStar={handleToggleStar}
|
handleToggleRead(entry);
|
||||||
onMarkAllRead={handleMarkAllRead}
|
}
|
||||||
onUpdateEntry={(updated) => {
|
|
||||||
setActiveArticle(updated);
|
|
||||||
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
|
||||||
}}
|
}}
|
||||||
sidebarCollapsed={sidebarCollapsed}
|
onToggleStar={handleToggleStar}
|
||||||
|
onToggleRead={handleToggleRead}
|
||||||
|
onMarkAllRead={handleMarkAllRead}
|
||||||
|
onUpdateEntry={handleUpdateEntry}
|
||||||
|
stats={stats}
|
||||||
|
totalEntries={totalEntries}
|
||||||
|
onOpenSettings={() => setViewMode('settings')}
|
||||||
|
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||||
|
onPlayAudio={handlePlayAudio}
|
||||||
|
currentAudioUrl={currentAudioUrl}
|
||||||
|
isPlayingAudio={isPlayingAudio}
|
||||||
/>
|
/>
|
||||||
) : viewMode === 'waterfall' ? (
|
) : viewMode === 'waterfall' ? (
|
||||||
<WaterfallView
|
<WaterfallView
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
feeds={feeds}
|
||||||
entries={entries}
|
entries={entries}
|
||||||
|
stats={stats}
|
||||||
|
currentUser={currentUser}
|
||||||
onToggleRead={handleToggleRead}
|
onToggleRead={handleToggleRead}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onUpdateEntry={(updated) => {
|
onUpdateEntry={handleUpdateEntry}
|
||||||
setActiveArticle(updated);
|
onRefreshData={() => {
|
||||||
setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
|
loadSidebarData();
|
||||||
|
loadEntries();
|
||||||
}}
|
}}
|
||||||
|
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||||
|
onPlayAudio={handlePlayAudio}
|
||||||
|
currentAudioUrl={currentAudioUrl}
|
||||||
|
isPlayingAudio={isPlayingAudio}
|
||||||
|
onTogglePlayAudio={handleTogglePlayAudio}
|
||||||
|
onCloseAudio={handleCloseAudio}
|
||||||
|
playingEntry={playingEntry}
|
||||||
|
viewPreference={viewPreference}
|
||||||
|
onChangeViewPreference={handleChangeViewPreference}
|
||||||
|
onSwitchToDesktop={() => setViewMode('split')}
|
||||||
|
hasMore={entries.length < totalEntries}
|
||||||
|
isLoadingMore={isLoadingMore}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SettingsView
|
<SettingsView
|
||||||
@@ -530,10 +596,13 @@ export const App: React.FC = () => {
|
|||||||
loadEntries();
|
loadEntries();
|
||||||
}}
|
}}
|
||||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||||
|
onBack={() => setViewMode(lastContentMode)}
|
||||||
|
viewPreference={viewPreference}
|
||||||
|
onChangeViewPreference={handleChangeViewPreference}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Shared Modal Dialogs */}
|
||||||
{showAddFeedModal && (
|
{showAddFeedModal && (
|
||||||
<AddFeedModal
|
<AddFeedModal
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Category, Feed } from '../types';
|
|
||||||
import { api } from '../services/api';
|
|
||||||
import { X, Plus, Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface AddFeedModalProps {
|
|
||||||
categories: Category[];
|
|
||||||
onClose: () => void;
|
|
||||||
onFeedAdded: (feed: Feed) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
|
|
||||||
categories,
|
|
||||||
onClose,
|
|
||||||
onFeedAdded,
|
|
||||||
}) => {
|
|
||||||
const [feedUrl, setFeedUrl] = useState('');
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [categoryId, setCategoryId] = useState<string>('');
|
|
||||||
const [newCatTitle, setNewCatTitle] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!feedUrl.trim()) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let finalCatId: string | undefined = categoryId || undefined;
|
|
||||||
|
|
||||||
// If user typed a new category name
|
|
||||||
if (newCatTitle.trim()) {
|
|
||||||
const newCat = await api.createCategory(newCatTitle.trim());
|
|
||||||
finalCatId = newCat.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newFeed = await api.createFeed(
|
|
||||||
feedUrl.trim(),
|
|
||||||
finalCatId,
|
|
||||||
title.trim() || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
onFeedAdded(newFeed);
|
|
||||||
onClose();
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Failed to add feed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h3>Add New Subscription</h3>
|
|
||||||
<button className="btn-icon" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="modal-body">
|
|
||||||
{error && <div className="alert-danger">{error}</div>}
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Feed or Website URL *</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
className="form-input"
|
|
||||||
value={feedUrl}
|
|
||||||
onChange={(e) => setFeedUrl(e.target.value)}
|
|
||||||
placeholder="https://news.ycombinator.com/rss"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Custom Title (Optional)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Leave blank to auto-detect from feed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Category / Folder</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
value={categoryId}
|
|
||||||
onChange={(e) => setCategoryId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">(No Category)</option>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label">Or Create New Category</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
value={newCatTitle}
|
|
||||||
onChange={(e) => setNewCatTitle(e.target.value)}
|
|
||||||
placeholder="e.g. AI & Tech"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.5rem' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={loading || !feedUrl.trim()}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={16} className="animate-spin" /> Verifying...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus size={16} /> Subscribe
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { FeedEntry } from '../types';
|
|
||||||
import { estimateReadingTime } from '../utils';
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Star,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Share2,
|
|
||||||
Clock,
|
|
||||||
User as UserIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface ArticleModalProps {
|
|
||||||
entry: FeedEntry;
|
|
||||||
onClose: () => void;
|
|
||||||
onToggleStar: (e?: React.MouseEvent) => void;
|
|
||||||
onToggleRead: (e?: React.MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ArticleModal: React.FC<ArticleModalProps> = ({
|
|
||||||
entry,
|
|
||||||
onClose,
|
|
||||||
onToggleStar,
|
|
||||||
onToggleRead,
|
|
||||||
}) => {
|
|
||||||
const shareUrl = `${window.location.origin}/share/${entry.share_uuid || entry.id}.html`;
|
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
||||||
if (entry.is_read) return;
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
|
||||||
if (scrollHeight <= clientHeight + 40) return;
|
|
||||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
|
||||||
if (scrollProgress >= 0.9) {
|
|
||||||
onToggleRead();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="article-drawer-overlay" onClick={onClose}>
|
|
||||||
<div
|
|
||||||
className="article-drawer-card"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Top Header Actions */}
|
|
||||||
<div className="article-drawer-header">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
color: entry.is_starred ? 'var(--star)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={onToggleStar}
|
|
||||||
title={entry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={17}
|
|
||||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
color: entry.is_read ? 'var(--success)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={onToggleRead}
|
|
||||||
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={17} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginLeft: '0.5rem' }}>
|
|
||||||
{entry.feed_title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
||||||
<a
|
|
||||||
href={shareUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-secondary"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
|
||||||
>
|
|
||||||
<Share2 size={13} />
|
|
||||||
<span>Share</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={entry.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-secondary"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={13} />
|
|
||||||
<span>Original Link</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button className="btn-icon" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Article Scroll Body */}
|
|
||||||
<div className="article-drawer-scroll" onScroll={handleScroll}>
|
|
||||||
<div className="reader-canvas" style={{ padding: 0 }}>
|
|
||||||
<div className="reader-source-bar">
|
|
||||||
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
|
||||||
{entry.feed_title}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<Clock size={13} />{' '}
|
|
||||||
{new Date(entry.published_at).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
|
||||||
{entry.author && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<UserIcon size={13} /> {entry.author}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="reader-headline">
|
|
||||||
<a href={entry.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{entry.title}
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="reader-body"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: entry.content || entry.raw_content || '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import { FeedEntry } from '../types';
|
|
||||||
import {
|
|
||||||
Star,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Share2,
|
|
||||||
Clock,
|
|
||||||
User as UserIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface CompactListViewProps {
|
|
||||||
entries: FeedEntry[];
|
|
||||||
selectedIndex: number;
|
|
||||||
onSelectEntry: (index: number) => void;
|
|
||||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
||||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CompactListView: React.FC<CompactListViewProps> = ({
|
|
||||||
entries,
|
|
||||||
selectedIndex,
|
|
||||||
onSelectEntry,
|
|
||||||
onToggleRead,
|
|
||||||
onToggleStar,
|
|
||||||
}) => {
|
|
||||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
|
||||||
const el = rowRefs.current[selectedIndex];
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedIndex, entries.length]);
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">🎉</div>
|
|
||||||
<h3>Inbox Zero!</h3>
|
|
||||||
<p>No articles to display in this view.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedEntry = entries[selectedIndex] || entries[0];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="compact-layout">
|
|
||||||
{/* Left List Pane (Gmail style) */}
|
|
||||||
<div className="compact-list">
|
|
||||||
{entries.map((entry, index) => {
|
|
||||||
const isSelected = index === selectedIndex;
|
|
||||||
const pubDate = new Date(entry.published_at).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strip HTML tags for clean 1-line snippet
|
|
||||||
const snippet = (entry.content || entry.raw_content || '')
|
|
||||||
.replace(/<[^>]*>?/gm, '')
|
|
||||||
.substring(0, 120);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
ref={(el) => (rowRefs.current[index] = el)}
|
|
||||||
className={`compact-row ${isSelected ? 'focused' : ''} ${
|
|
||||||
entry.is_read ? 'is-read' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSelectEntry(index)}
|
|
||||||
>
|
|
||||||
{/* Star toggle */}
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
color: entry.is_starred ? 'var(--star)' : 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => onToggleStar(entry, e)}
|
|
||||||
title={entry.is_starred ? 'Unstar' : 'Star'}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={15}
|
|
||||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Mark read toggle */}
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
color: entry.is_read ? 'var(--success)' : 'var(--unread-badge)',
|
|
||||||
}}
|
|
||||||
onClick={(e) => onToggleRead(entry, e)}
|
|
||||||
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={15} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Feed Title */}
|
|
||||||
<div className="compact-row-feed" title={entry.feed_title}>
|
|
||||||
{entry.feed_title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Article Title & Snippet */}
|
|
||||||
<div className="compact-row-main">
|
|
||||||
<span className="compact-row-title">{entry.title}</span>
|
|
||||||
<span className="compact-row-snippet"> - {snippet}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div className="compact-row-date">{pubDate}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Reading Pane */}
|
|
||||||
{selectedEntry && (
|
|
||||||
<div className="compact-reading-pane">
|
|
||||||
<div className="reading-pane-header">
|
|
||||||
<div className="stream-source-meta">
|
|
||||||
<span className="stream-source-badge">{selectedEntry.feed_title}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<Clock size={12} />{' '}
|
|
||||||
{new Date(selectedEntry.published_at).toLocaleString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
{selectedEntry.author && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<UserIcon size={12} /> {selectedEntry.author}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="reading-pane-title">
|
|
||||||
<a
|
|
||||||
href={selectedEntry.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
{selectedEntry.title} ↗
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Actions Toolbar */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginTop: '1rem' }}>
|
|
||||||
<button
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={() => onToggleRead(selectedEntry)}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={15} />
|
|
||||||
<span>{selectedEntry.is_read ? 'Mark as Unread (M)' : 'Mark as Read (M)'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-secondary"
|
|
||||||
style={{ color: selectedEntry.is_starred ? 'var(--star)' : undefined }}
|
|
||||||
onClick={() => onToggleStar(selectedEntry)}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={15}
|
|
||||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
|
||||||
/>
|
|
||||||
<span>{selectedEntry.is_starred ? 'Starred (S)' : 'Star (S)'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={selectedEntry.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
<ExternalLink size={15} />
|
|
||||||
<span>Open Original (V)</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
<Share2 size={15} />
|
|
||||||
<span>Public Share</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="reading-pane-body"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import React, { useRef, useEffect, useState } from 'react';
|
|
||||||
import { ViewMode } from '../types';
|
|
||||||
import {
|
|
||||||
Rss,
|
|
||||||
Search,
|
|
||||||
Columns3,
|
|
||||||
LayoutGrid,
|
|
||||||
Settings,
|
|
||||||
RefreshCw,
|
|
||||||
Plus,
|
|
||||||
HelpCircle,
|
|
||||||
LogOut,
|
|
||||||
PanelLeft,
|
|
||||||
PanelLeftClose,
|
|
||||||
MoreVertical,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
search: string;
|
|
||||||
onSearchChange: (val: string) => void;
|
|
||||||
viewMode: ViewMode;
|
|
||||||
onViewModeChange: (mode: ViewMode) => void;
|
|
||||||
isRefreshing: boolean;
|
|
||||||
onRefreshAll: () => void;
|
|
||||||
onOpenAddFeed: () => void;
|
|
||||||
onOpenShortcuts: () => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
sidebarCollapsed: boolean;
|
|
||||||
onToggleSidebar: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
viewMode,
|
|
||||||
onViewModeChange,
|
|
||||||
isRefreshing,
|
|
||||||
onRefreshAll,
|
|
||||||
onOpenAddFeed,
|
|
||||||
onOpenShortcuts,
|
|
||||||
onLogout,
|
|
||||||
sidebarCollapsed,
|
|
||||||
onToggleSidebar,
|
|
||||||
}) => {
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
setIsMobile(window.innerWidth < 768);
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
e.key === '/' &&
|
|
||||||
document.activeElement?.tagName !== 'INPUT' &&
|
|
||||||
document.activeElement?.tagName !== 'TEXTAREA'
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
searchInputRef.current?.select();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="app-header">
|
|
||||||
{/* Left: Logo & Sidebar Toggle */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
|
||||||
{!isMobile && viewMode === 'split' && (
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
title={sidebarCollapsed ? 'Show Sidebar ( [ )' : 'Hide Sidebar ( [ )'}
|
|
||||||
>
|
|
||||||
{sidebarCollapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="logo-area"
|
|
||||||
onClick={() => onViewModeChange(isMobile ? 'waterfall' : 'split')}
|
|
||||||
>
|
|
||||||
<div style={{ color: 'var(--accent)', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Rss size={19} />
|
|
||||||
</div>
|
|
||||||
<span>RSSPipe</span>
|
|
||||||
{!isMobile && <span className="logo-badge">Gateway</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: Search Bar */}
|
|
||||||
<div className="search-bar-container">
|
|
||||||
<Search size={14} className="search-icon" />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
className="search-input"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
|
||||||
placeholder={isMobile ? 'Search...' : "Search feeds, 'is:unread'..."}
|
|
||||||
/>
|
|
||||||
{!search && !isMobile && <span className="search-shortcut-hint">/</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: Actions */}
|
|
||||||
{isMobile ? (
|
|
||||||
// Mobile Header Right: 3 Dots Menu Button
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
|
||||||
title="Menu"
|
|
||||||
>
|
|
||||||
<MoreVertical size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{mobileMenuOpen && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 40 }}
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
/>
|
|
||||||
<div className="mobile-menu-dropdown">
|
|
||||||
<button
|
|
||||||
className="mobile-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
onOpenAddFeed();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={15} />
|
|
||||||
<span>Add Feed</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="mobile-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
onRefreshAll();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
|
|
||||||
<span>Refresh Feeds</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="mobile-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
onViewModeChange('waterfall');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={15} />
|
|
||||||
<span>Waterfall Feed</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="mobile-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
onViewModeChange('split');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Columns3 size={15} />
|
|
||||||
<span>3-Split View</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="mobile-menu-item"
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
onViewModeChange('settings');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings size={15} />
|
|
||||||
<span>Settings</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="mobile-menu-item"
|
|
||||||
style={{ color: 'var(--danger)' }}
|
|
||||||
onClick={() => {
|
|
||||||
setMobileMenuOpen(false);
|
|
||||||
onLogout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogOut size={15} />
|
|
||||||
<span>Sign Out</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Desktop Header Right Actions
|
|
||||||
<div className="header-actions">
|
|
||||||
<button
|
|
||||||
className="btn-primary"
|
|
||||||
onClick={onOpenAddFeed}
|
|
||||||
title="Add new subscription (A)"
|
|
||||||
>
|
|
||||||
<Plus size={14} />
|
|
||||||
<span>Add Feed</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={onRefreshAll}
|
|
||||||
title="Refresh all feeds (R)"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
|
||||||
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="view-mode-pill">
|
|
||||||
<button
|
|
||||||
className={`btn-icon ${viewMode === 'split' ? 'active' : ''}`}
|
|
||||||
style={{ width: 28, height: 28 }}
|
|
||||||
onClick={() => onViewModeChange('split')}
|
|
||||||
title="Desktop 3-Pane Split View (1)"
|
|
||||||
>
|
|
||||||
<Columns3 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn-icon ${viewMode === 'waterfall' ? 'active' : ''}`}
|
|
||||||
style={{ width: 28, height: 28 }}
|
|
||||||
onClick={() => onViewModeChange('waterfall')}
|
|
||||||
title="Xiaohongshu Waterfall View (2)"
|
|
||||||
>
|
|
||||||
<LayoutGrid size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn-icon ${viewMode === 'settings' ? 'active' : ''}`}
|
|
||||||
style={{ width: 28, height: 28 }}
|
|
||||||
onClick={() => onViewModeChange('settings')}
|
|
||||||
title="Settings & Subscriptions (3)"
|
|
||||||
>
|
|
||||||
<Settings size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={onOpenShortcuts}
|
|
||||||
title="Keyboard shortcuts (?)"
|
|
||||||
>
|
|
||||||
<HelpCircle size={15} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={onLogout}
|
|
||||||
title="Sign Out"
|
|
||||||
style={{ color: 'var(--danger)' }}
|
|
||||||
>
|
|
||||||
<LogOut size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { X, Keyboard } from 'lucide-react';
|
|
||||||
|
|
||||||
interface KeyboardShortcutsModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const shortcuts = [
|
|
||||||
{ key: 'j / k', desc: 'Navigate to Next / Previous article' },
|
|
||||||
{ key: 'o / Enter', desc: 'Open / Expand selected article' },
|
|
||||||
{ key: 'v', desc: 'Open original link in a new browser tab' },
|
|
||||||
{ key: 's', desc: 'Star / Unstar article' },
|
|
||||||
{ key: 'm', desc: 'Toggle Read / Unread status' },
|
|
||||||
{ key: 'e / Shift+I', desc: 'Archive / Mark Read and advance to next' },
|
|
||||||
{ key: 'Shift + A', desc: 'Mark all items in current view as read' },
|
|
||||||
{ key: '/', desc: 'Focus search bar' },
|
|
||||||
{ key: 'r', desc: 'Refresh all feeds' },
|
|
||||||
{ key: '1', desc: 'Switch to Reeder 4 Minimal 3-Pane Split View' },
|
|
||||||
{ key: '2', desc: 'Switch to Xiaohongshu Waterfall / Masonry View' },
|
|
||||||
{ key: '3', desc: 'Switch to Continuous Stream View' },
|
|
||||||
{ key: 'a', desc: 'Add new subscription modal' },
|
|
||||||
{ key: '?', desc: 'Open keyboard shortcuts cheatsheet' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<Keyboard size={20} style={{ color: 'var(--accent)' }} />
|
|
||||||
<h3>Google Reader & Gmail Keyboard Shortcuts</h3>
|
|
||||||
</div>
|
|
||||||
<button className="btn-icon" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
|
|
||||||
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
||||||
SHORTCUT
|
|
||||||
</th>
|
|
||||||
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
||||||
ACTION
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{shortcuts.map((sc, i) => (
|
|
||||||
<tr
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
borderBottom: '1px solid var(--border-light)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td style={{ padding: '0.65rem 0.75rem' }}>
|
|
||||||
<kbd
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-main)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: '3px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sc.key}
|
|
||||||
</kbd>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '0.65rem 0.75rem', color: 'var(--text-secondary)' }}>
|
|
||||||
{sc.desc}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
||||||
Press <kbd style={{ padding: '2px 5px', border: '1px solid var(--border)', borderRadius: 3 }}>ESC</kbd> to close any modal.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { api } from '../services/api';
|
|
||||||
import { User } from '../types';
|
|
||||||
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
interface LoginViewProps {
|
|
||||||
onLoginSuccess: (user: User) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
|
||||||
const [username, setUsername] = useState('admin');
|
|
||||||
const [password, setPassword] = useState('admin123456');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await api.login(username, password);
|
|
||||||
onLoginSuccess(res.user);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Login failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="login-container">
|
|
||||||
<div className="login-card">
|
|
||||||
<div className="login-logo">
|
|
||||||
<div style={{ width: 36, height: 36, color: 'var(--accent)' }}>
|
|
||||||
<Rss size={36} />
|
|
||||||
</div>
|
|
||||||
<span>RSSPipe</span>
|
|
||||||
</div>
|
|
||||||
<p className="login-subtitle">RSS-Driven Information Gateway</p>
|
|
||||||
|
|
||||||
{error && <div className="alert-danger">{error}</div>}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<UserIcon size={14} /> Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
placeholder="admin"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Lock size={14} /> Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="form-input"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary"
|
|
||||||
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', marginTop: '1rem', fontSize: '0.95rem' }}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={18} className="animate-spin" /> Signing in...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Sign In <ArrowRight size={18} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
|
||||||
Default single-user credentials: <code>admin</code> / <code>admin123456</code>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Category, Feed, FilterState, Stats } from '../types';
|
|
||||||
import {
|
|
||||||
Inbox,
|
|
||||||
Star,
|
|
||||||
Layers,
|
|
||||||
Folder,
|
|
||||||
FolderOpen,
|
|
||||||
Rss,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronDown,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
filter: FilterState;
|
|
||||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
|
||||||
categories: Category[];
|
|
||||||
feeds: Feed[];
|
|
||||||
stats: Stats | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
|
||||||
filter,
|
|
||||||
onFilterChange,
|
|
||||||
categories,
|
|
||||||
feeds,
|
|
||||||
stats,
|
|
||||||
}) => {
|
|
||||||
const [collapsedCats, setCollapsedCats] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group feeds by category
|
|
||||||
const categorizedFeeds: Record<string, Feed[]> = {};
|
|
||||||
const uncategorizedFeeds: Feed[] = [];
|
|
||||||
|
|
||||||
feeds.forEach((f) => {
|
|
||||||
if (f.category_id) {
|
|
||||||
if (!categorizedFeeds[f.category_id]) {
|
|
||||||
categorizedFeeds[f.category_id] = [];
|
|
||||||
}
|
|
||||||
categorizedFeeds[f.category_id].push(f);
|
|
||||||
} else {
|
|
||||||
uncategorizedFeeds.push(f);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sidebar-scroll-area">
|
|
||||||
{/* Core Navigation Items */}
|
|
||||||
<div
|
|
||||||
className={`sidebar-nav-item ${
|
|
||||||
filter.view === 'inbox' && !filter.feedId && !filter.categoryId
|
|
||||||
? 'active'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
view: 'inbox',
|
|
||||||
feedId: undefined,
|
|
||||||
categoryId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="sidebar-nav-label">
|
|
||||||
<Inbox size={16} />
|
|
||||||
<span>Inbox</span>
|
|
||||||
</div>
|
|
||||||
{stats && stats.total_unread > 0 && (
|
|
||||||
<span className="badge unread">{stats.total_unread}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`sidebar-nav-item ${
|
|
||||||
filter.view === 'starred' && !filter.feedId && !filter.categoryId
|
|
||||||
? 'active'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
view: 'starred',
|
|
||||||
feedId: undefined,
|
|
||||||
categoryId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="sidebar-nav-label">
|
|
||||||
<Star size={16} style={{ color: 'var(--star)' }} />
|
|
||||||
<span>Starred</span>
|
|
||||||
</div>
|
|
||||||
{stats && stats.total_starred > 0 && (
|
|
||||||
<span className="badge">{stats.total_starred}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`sidebar-nav-item ${
|
|
||||||
filter.view === 'all' && !filter.feedId && !filter.categoryId
|
|
||||||
? 'active'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
view: 'all',
|
|
||||||
feedId: undefined,
|
|
||||||
categoryId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="sidebar-nav-label">
|
|
||||||
<Layers size={16} />
|
|
||||||
<span>All Articles</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Categories Section */}
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.6rem' }}>
|
|
||||||
<div className="sidebar-section-header">
|
|
||||||
<span>Categories</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{categories.map((cat) => {
|
|
||||||
const catFeeds = categorizedFeeds[cat.id] || [];
|
|
||||||
const isCollapsed = collapsedCats[cat.id];
|
|
||||||
const isCatActive = filter.categoryId === cat.id && !filter.feedId;
|
|
||||||
const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={cat.id}>
|
|
||||||
<div
|
|
||||||
className={`sidebar-nav-item ${isCatActive ? 'active' : ''}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
categoryId: cat.id,
|
|
||||||
feedId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="sidebar-nav-label">
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'inherit',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 0,
|
|
||||||
marginRight: -3,
|
|
||||||
}}
|
|
||||||
onClick={(e) => toggleCategoryCollapse(cat.id, e)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
|
||||||
</button>
|
|
||||||
{isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />}
|
|
||||||
<span>{cat.title}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{catUnread > 0 && <span className="badge unread">{catUnread}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category feeds */}
|
|
||||||
{!isCollapsed &&
|
|
||||||
catFeeds.map((feed) => {
|
|
||||||
const isFeedActive = filter.feedId === feed.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feed.id}
|
|
||||||
className={`feed-item ${isFeedActive ? 'active' : ''}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
feedId: feed.id,
|
|
||||||
categoryId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Rss size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span className="feed-title-text" title={feed.title}>
|
|
||||||
{feed.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{feed.unread_count > 0 && (
|
|
||||||
<span className="badge unread">{feed.unread_count}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Uncategorized Feeds */}
|
|
||||||
{uncategorizedFeeds.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.6rem' }}>
|
|
||||||
<div className="sidebar-section-header">
|
|
||||||
<span>Feeds</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{uncategorizedFeeds.map((feed) => {
|
|
||||||
const isFeedActive = filter.feedId === feed.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feed.id}
|
|
||||||
className={`sidebar-nav-item ${isFeedActive ? 'active' : ''}`}
|
|
||||||
style={{ paddingLeft: '0.65rem' }}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
feedId: feed.id,
|
|
||||||
categoryId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="sidebar-nav-label">
|
|
||||||
<Rss size={14} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span className="feed-title-text" title={feed.title}>
|
|
||||||
{feed.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{feed.unread_count > 0 && (
|
|
||||||
<span className="badge unread">{feed.unread_count}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,569 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
|
||||||
import { Sidebar } from './Sidebar';
|
|
||||||
import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
|
||||||
import { api } from '../services/api';
|
|
||||||
import {
|
|
||||||
Star,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Share2,
|
|
||||||
Clock,
|
|
||||||
User as UserIcon,
|
|
||||||
CheckCheck,
|
|
||||||
FileText,
|
|
||||||
Zap,
|
|
||||||
Sparkles,
|
|
||||||
Volume2,
|
|
||||||
Loader2,
|
|
||||||
Copy,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface ThreePaneViewProps {
|
|
||||||
filter: FilterState;
|
|
||||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
|
||||||
categories: Category[];
|
|
||||||
feeds: Feed[];
|
|
||||||
stats: Stats | null;
|
|
||||||
entries: FeedEntry[];
|
|
||||||
selectedIndex: number;
|
|
||||||
activeArticle?: FeedEntry | null;
|
|
||||||
onSelectEntry: (index: number) => void;
|
|
||||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
||||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
||||||
onMarkAllRead: () => void;
|
|
||||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
|
||||||
sidebarCollapsed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
|
||||||
filter,
|
|
||||||
onFilterChange,
|
|
||||||
categories,
|
|
||||||
feeds,
|
|
||||||
stats,
|
|
||||||
entries,
|
|
||||||
selectedIndex,
|
|
||||||
activeArticle,
|
|
||||||
onSelectEntry,
|
|
||||||
onToggleRead,
|
|
||||||
onToggleStar,
|
|
||||||
onMarkAllRead,
|
|
||||||
onUpdateEntry,
|
|
||||||
sidebarCollapsed,
|
|
||||||
}) => {
|
|
||||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
|
||||||
const summaryCardRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
|
||||||
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
|
||||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
|
|
||||||
|
|
||||||
// Auto-scroll list to active card
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
|
||||||
const el = cardRefs.current[selectedIndex];
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [selectedIndex, entries.length]);
|
|
||||||
|
|
||||||
// Reset variant selection when article changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedEntry?.variants?.['readability_full_text']) {
|
|
||||||
// If full text is available, you can choose to stay on current or default to original
|
|
||||||
}
|
|
||||||
}, [selectedEntry?.id]);
|
|
||||||
|
|
||||||
// When switching selected article, reset reader scroll position to top and handle 2s timer for short content
|
|
||||||
useEffect(() => {
|
|
||||||
if (readerPaneRef.current) {
|
|
||||||
readerPaneRef.current.scrollTop = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedEntry || selectedEntry.is_read) return;
|
|
||||||
|
|
||||||
const autoReadBottom = localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
|
|
||||||
if (!autoReadBottom) return;
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// Small delay to allow DOM layout calculation
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (readerPaneRef.current) {
|
|
||||||
const { scrollHeight, clientHeight } = readerPaneRef.current;
|
|
||||||
// Condition 2: If content is < 1 page (not scrollable, already at bottom), mark as read after 2 seconds
|
|
||||||
if (scrollHeight <= clientHeight + 35) {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (selectedEntry && !selectedEntry.is_read) {
|
|
||||||
onToggleRead(selectedEntry);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
|
||||||
|
|
||||||
// Reading Detection 1: Any active scroll on content >= 1 page immediately marks as read
|
|
||||||
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
||||||
if (!selectedEntry || selectedEntry.is_read) return;
|
|
||||||
const { scrollTop } = e.currentTarget;
|
|
||||||
if (scrollTop > 0) {
|
|
||||||
onToggleRead(selectedEntry);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reading Detection 2: Clicking anywhere inside the content canvas marks as read
|
|
||||||
const handleContentPaneClick = () => {
|
|
||||||
if (selectedEntry && !selectedEntry.is_read) {
|
|
||||||
onToggleRead(selectedEntry);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScrapeFullText = async () => {
|
|
||||||
if (!selectedEntry || isScraping) return;
|
|
||||||
setIsScraping(true);
|
|
||||||
try {
|
|
||||||
const updated = await api.scrapeEntry(selectedEntry.id);
|
|
||||||
if (onUpdateEntry) {
|
|
||||||
onUpdateEntry(updated);
|
|
||||||
}
|
|
||||||
setSelectedVariantKey('readability_full_text');
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(`Scraping failed: ${err.message || err}`);
|
|
||||||
} finally {
|
|
||||||
setIsScraping(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getListTitle = () => {
|
|
||||||
if (filter.search) return `Search: "${filter.search}"`;
|
|
||||||
if (filter.feedId) {
|
|
||||||
const f = feeds.find((item) => item.id === filter.feedId);
|
|
||||||
return f ? f.title : 'Feed';
|
|
||||||
}
|
|
||||||
if (filter.categoryId) {
|
|
||||||
const c = categories.find((item) => item.id === filter.categoryId);
|
|
||||||
return c ? c.title : 'Category';
|
|
||||||
}
|
|
||||||
if (filter.view === 'starred') return 'Starred';
|
|
||||||
if (filter.view === 'all') return 'All Articles';
|
|
||||||
return 'Inbox';
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
|
|
||||||
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
|
|
||||||
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
|
|
||||||
|
|
||||||
const getRenderedContent = () => {
|
|
||||||
if (!selectedEntry) return '';
|
|
||||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
|
||||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
|
||||||
}
|
|
||||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="three-pane-container">
|
|
||||||
{/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */}
|
|
||||||
<div className={`sidebar-pane ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<Sidebar
|
|
||||||
filter={filter}
|
|
||||||
onFilterChange={onFilterChange}
|
|
||||||
categories={categories}
|
|
||||||
feeds={feeds}
|
|
||||||
stats={stats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pane 2: Article List (Reeder 4 minimal style) */}
|
|
||||||
<div className="article-list-pane">
|
|
||||||
<div className="article-list-header">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 700, fontSize: '0.85rem' }}>
|
|
||||||
<span>{getListTitle()}</span>
|
|
||||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 500 }}>
|
|
||||||
{entries.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
onClick={onMarkAllRead}
|
|
||||||
title="Mark all as read (Shift + A)"
|
|
||||||
style={{ width: 28, height: 28 }}
|
|
||||||
>
|
|
||||||
<CheckCheck size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="article-list-scroll-area">
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
|
||||||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>✨</div>
|
|
||||||
<h3 style={{ fontSize: '0.9rem' }}>Inbox Zero</h3>
|
|
||||||
<p style={{ fontSize: '0.75rem' }}>No articles to read here.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
entries.map((entry, index) => {
|
|
||||||
const isSelected = index === selectedIndex;
|
|
||||||
const snippet = stripHtml(entry.content || entry.raw_content, 100);
|
|
||||||
const relativeTime = formatRelativeDate(entry.published_at);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
ref={(el) => (cardRefs.current[index] = el)}
|
|
||||||
className={`article-item-card ${isSelected ? 'selected' : ''} ${
|
|
||||||
entry.is_read ? 'is-read' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSelectEntry(index)}
|
|
||||||
>
|
|
||||||
<div className="article-item-top">
|
|
||||||
<span className="article-item-source">{entry.feed_title}</span>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span>{relativeTime}</span>
|
|
||||||
{!entry.is_read && <div className="unread-dot" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
|
||||||
|
|
||||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
|
||||||
|
|
||||||
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
|
||||||
{getEntryTags(entry.variants).map((tag) => (
|
|
||||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
|
||||||
))}
|
|
||||||
{entry.variants?.['ai_summary'] && (
|
|
||||||
<span
|
|
||||||
className="variant-tag-chip"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.08)',
|
|
||||||
color: '#34d399',
|
|
||||||
borderColor: 'rgba(16, 185, 129, 0.25)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✨ AI Summary
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pane 3: Distraction-Free Reading Canvas */}
|
|
||||||
<div
|
|
||||||
className="reader-pane"
|
|
||||||
ref={readerPaneRef}
|
|
||||||
onScroll={handleReaderScroll}
|
|
||||||
onClick={handleContentPaneClick}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{selectedEntry ? (
|
|
||||||
<>
|
|
||||||
{/* Top Toolbar */}
|
|
||||||
<div className="reader-toolbar" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleStar(selectedEntry)}
|
|
||||||
title={selectedEntry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={16}
|
|
||||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleRead(selectedEntry)}
|
|
||||||
title={selectedEntry.is_read ? 'Mark Unread (M)' : 'Mark Read (M)'}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Sliding Ball Capsule Toggle Button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`capsule-switch-btn ${
|
|
||||||
selectedVariantKey === 'readability_full_text' ? 'active' : ''
|
|
||||||
} ${isScraping ? 'scraping' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isScraping) return;
|
|
||||||
if (selectedVariantKey === 'readability_full_text') {
|
|
||||||
setSelectedVariantKey('original');
|
|
||||||
} else {
|
|
||||||
if (hasReadability) {
|
|
||||||
setSelectedVariantKey('readability_full_text');
|
|
||||||
} else {
|
|
||||||
handleScrapeFullText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
selectedVariantKey === 'readability_full_text'
|
|
||||||
? 'Switch to Original RSS content'
|
|
||||||
: hasReadability
|
|
||||||
? 'Switch to Clean Full Text'
|
|
||||||
: 'Scrape and view Clean Full Text'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="capsule-switch-track">
|
|
||||||
<span className="capsule-switch-label">
|
|
||||||
{isScraping
|
|
||||||
? 'Scraping...'
|
|
||||||
: selectedVariantKey === 'readability_full_text'
|
|
||||||
? 'Full Text'
|
|
||||||
: 'Original'}
|
|
||||||
</span>
|
|
||||||
<div className="capsule-switch-thumb">
|
|
||||||
{isScraping ? (
|
|
||||||
<Loader2 size={12} className="spin" />
|
|
||||||
) : selectedVariantKey === 'readability_full_text' ? (
|
|
||||||
<Zap size={12} />
|
|
||||||
) : (
|
|
||||||
<FileText size={12} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasAISummary && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '3px 9px',
|
|
||||||
fontSize: '0.72rem',
|
|
||||||
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
|
||||||
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
|
||||||
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
const next = !showAISummary;
|
|
||||||
setShowAISummary(next);
|
|
||||||
if (next) {
|
|
||||||
setTimeout(() => {
|
|
||||||
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
|
||||||
>
|
|
||||||
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
|
||||||
<span>Summary</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasTTS && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '3px 9px',
|
|
||||||
fontSize: '0.72rem',
|
|
||||||
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
|
|
||||||
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
|
|
||||||
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}}
|
|
||||||
onClick={() => setSelectedVariantKey('tts')}
|
|
||||||
title="Audio Stream"
|
|
||||||
>
|
|
||||||
<Volume2 size={12} style={{ marginRight: 4 }} />
|
|
||||||
Audio
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
||||||
<a
|
|
||||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-secondary"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
|
||||||
>
|
|
||||||
<Share2 size={13} />
|
|
||||||
<span>Share</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={selectedEntry.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-secondary"
|
|
||||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={13} />
|
|
||||||
<span>Original Link (V)</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reading Content */}
|
|
||||||
<div className="reader-content-canvas">
|
|
||||||
<div className="article-header">
|
|
||||||
<div className="article-header-meta">
|
|
||||||
<span className="article-feed-badge">
|
|
||||||
{selectedEntry.feed_title}
|
|
||||||
</span>
|
|
||||||
<span className="article-meta-divider">•</span>
|
|
||||||
<span className="article-meta-item">
|
|
||||||
<Clock size={12} />
|
|
||||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="article-meta-divider">•</span>
|
|
||||||
<span className="article-meta-item">{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
|
||||||
{selectedEntry.author && (
|
|
||||||
<>
|
|
||||||
<span className="article-meta-divider">•</span>
|
|
||||||
<span className="article-meta-item">
|
|
||||||
<UserIcon size={12} /> {selectedEntry.author}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="reader-headline">
|
|
||||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{selectedEntry.title}
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{getEntryTags(selectedEntry.variants).length > 0 && (
|
|
||||||
<div className="article-tags-row">
|
|
||||||
{getEntryTags(selectedEntry.variants).map((tag) => (
|
|
||||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Audio player if TTS variant is selected */}
|
|
||||||
{selectedVariantKey === 'tts' && (
|
|
||||||
<div style={{ margin: '1rem 0', padding: '1rem', background: 'var(--bg-tertiary)', borderRadius: '8px' }}>
|
|
||||||
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>🎧 Audio Stream:</p>
|
|
||||||
<audio
|
|
||||||
controls
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* AI Summary Card if present and visible */}
|
|
||||||
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
|
||||||
<div
|
|
||||||
ref={summaryCardRef}
|
|
||||||
style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.05)',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.22)',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: '1rem 1.15rem',
|
|
||||||
marginBottom: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
|
||||||
<Sparkles size={15} />
|
|
||||||
<span>AI Summary & Takeaways</span>
|
|
||||||
{selectedEntry.variants.ai_summary.metadata?.model && (
|
|
||||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
|
||||||
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon"
|
|
||||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
|
||||||
title="Copy Summary"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedEntry.variants?.ai_summary?.text_value) {
|
|
||||||
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon"
|
|
||||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
|
||||||
title="Hide Summary"
|
|
||||||
onClick={() => setShowAISummary(false)}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.88rem',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="reader-body"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: getRenderedContent(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">📖</div>
|
|
||||||
<h3>No Article Selected</h3>
|
|
||||||
<p>Select an article from the list or press 'j' / 'k' to read.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { api } from '../services/api';
|
|
||||||
import { Category, FeedEntry, FilterState } from '../types';
|
|
||||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
|
|
||||||
import {
|
|
||||||
Star,
|
|
||||||
Inbox,
|
|
||||||
Star as StarIcon,
|
|
||||||
Layers,
|
|
||||||
Rss,
|
|
||||||
ArrowLeft,
|
|
||||||
Share2,
|
|
||||||
ExternalLink,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
User as UserIcon,
|
|
||||||
FileText,
|
|
||||||
Zap,
|
|
||||||
Loader2,
|
|
||||||
Sparkles,
|
|
||||||
Volume2,
|
|
||||||
Copy,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface WaterfallViewProps {
|
|
||||||
filter: FilterState;
|
|
||||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
|
||||||
categories: Category[];
|
|
||||||
entries: FeedEntry[];
|
|
||||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
||||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
|
||||||
onUpdateEntry?: (entry: FeedEntry) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
|
||||||
filter,
|
|
||||||
onFilterChange,
|
|
||||||
categories,
|
|
||||||
entries,
|
|
||||||
onToggleRead,
|
|
||||||
onToggleStar,
|
|
||||||
onUpdateEntry,
|
|
||||||
}) => {
|
|
||||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
|
||||||
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
|
||||||
const [showAISummary, setShowAISummary] = useState<boolean>(true);
|
|
||||||
const [isScraping, setIsScraping] = useState<boolean>(false);
|
|
||||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const summaryCardRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Reset variant selection when selected article changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedVariantKey('original');
|
|
||||||
}, [selectedEntry?.id]);
|
|
||||||
|
|
||||||
const handleScrapeFullText = async () => {
|
|
||||||
if (!selectedEntry || isScraping) return;
|
|
||||||
setIsScraping(true);
|
|
||||||
try {
|
|
||||||
const updated = await api.scrapeEntry(selectedEntry.id);
|
|
||||||
setSelectedEntry(updated);
|
|
||||||
onUpdateEntry?.(updated);
|
|
||||||
setSelectedVariantKey('readability_full_text');
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(`Scraping failed: ${err.message || err}`);
|
|
||||||
} finally {
|
|
||||||
setIsScraping(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
|
|
||||||
const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
|
|
||||||
const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
|
|
||||||
|
|
||||||
const getRenderedContent = () => {
|
|
||||||
if (!selectedEntry) return '';
|
|
||||||
if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
|
|
||||||
return selectedEntry.variants['readability_full_text'].text_value || '';
|
|
||||||
}
|
|
||||||
return selectedEntry.content || selectedEntry.raw_content || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// When card is clicked, open in full-screen reader view and immediately mark as read
|
|
||||||
const handleCardClick = (entry: FeedEntry) => {
|
|
||||||
setSelectedEntry(entry);
|
|
||||||
if (!entry.is_read) {
|
|
||||||
onToggleRead(entry);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="xhs-waterfall-container">
|
|
||||||
{/* Top Category Tabs (Xiaohongshu App Style - No Left Sidebar) */}
|
|
||||||
<div className="xhs-tabs-bar">
|
|
||||||
<button
|
|
||||||
className={`xhs-tab-item ${
|
|
||||||
filter.view === 'inbox' && !filter.categoryId ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
view: 'inbox',
|
|
||||||
categoryId: undefined,
|
|
||||||
feedId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Inbox size={13} />
|
|
||||||
<span>Inbox</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`xhs-tab-item ${
|
|
||||||
filter.view === 'starred' && !filter.categoryId ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
view: 'starred',
|
|
||||||
categoryId: undefined,
|
|
||||||
feedId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<StarIcon size={13} />
|
|
||||||
<span>Starred</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`xhs-tab-item ${
|
|
||||||
filter.view === 'all' && !filter.categoryId ? 'active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
view: 'all',
|
|
||||||
categoryId: undefined,
|
|
||||||
feedId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Layers size={13} />
|
|
||||||
<span>All</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Categories as Horizontal Chips */}
|
|
||||||
{categories.map((cat) => {
|
|
||||||
const isActive = filter.categoryId === cat.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
className={`xhs-tab-item ${isActive ? 'active' : ''}`}
|
|
||||||
onClick={() =>
|
|
||||||
onFilterChange({
|
|
||||||
categoryId: cat.id,
|
|
||||||
feedId: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>{cat.title}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* True Xiaohongshu Waterfall (Masonry Grid with Staggered Heights) */}
|
|
||||||
<div className="xhs-waterfall-scroll">
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">🎉</div>
|
|
||||||
<h3>Inbox Zero</h3>
|
|
||||||
<p>No articles to display in this feed.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="xhs-masonry">
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const coverImg = extractFirstImage(entry.content || entry.raw_content);
|
|
||||||
const snippet = stripHtml(entry.content || entry.raw_content, 90);
|
|
||||||
const relativeDate = formatRelativeDate(entry.published_at);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={entry.id} className="xhs-masonry-item">
|
|
||||||
<div
|
|
||||||
className={`xhs-card ${entry.is_read ? 'is-read' : ''}`}
|
|
||||||
onClick={() => handleCardClick(entry)}
|
|
||||||
>
|
|
||||||
{/* Card Cover (Xiaohongshu style image / fallback) */}
|
|
||||||
<div className="xhs-cover-wrap">
|
|
||||||
{coverImg ? (
|
|
||||||
<img
|
|
||||||
src={coverImg}
|
|
||||||
alt=""
|
|
||||||
className="xhs-cover-img"
|
|
||||||
loading="lazy"
|
|
||||||
onError={(e) => {
|
|
||||||
(e.target as HTMLElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="xhs-cover-fallback">
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
|
||||||
<Rss size={20} />
|
|
||||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{entry.feed_title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card Body */}
|
|
||||||
<div className="xhs-card-body">
|
|
||||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
|
||||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
|
||||||
|
|
||||||
{(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{entry.variants?.['ai_summary'] && (
|
|
||||||
<span
|
|
||||||
className="variant-tag-chip"
|
|
||||||
style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.08)',
|
|
||||||
color: '#34d399',
|
|
||||||
borderColor: 'rgba(16, 185, 129, 0.25)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✨ AI Summary
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="xhs-card-footer">
|
|
||||||
<div className="xhs-publisher-badge">
|
|
||||||
<span>{entry.feed_title}</span>
|
|
||||||
<span style={{ margin: '0 3px' }}>•</span>
|
|
||||||
<span>{relativeDate}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
{!entry.is_read && <div className="unread-dot" />}
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
color: entry.is_starred ? 'var(--star)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleStar(entry, e);
|
|
||||||
}}
|
|
||||||
title={entry.is_starred ? 'Unstar' : 'Star'}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={13}
|
|
||||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Full-Screen Reading View with Back Arrow (Xiaohongshu post reader UX) */}
|
|
||||||
{selectedEntry && (
|
|
||||||
<div className="mobile-post-reader">
|
|
||||||
{/* Top Bar with Back Arrow */}
|
|
||||||
<div className="mobile-post-reader-header">
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{ width: 34, height: 34 }}
|
|
||||||
onClick={() => setSelectedEntry(null)}
|
|
||||||
title="Back to feed"
|
|
||||||
>
|
|
||||||
<ArrowLeft size={18} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
maxWidth: '40vw',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedEntry.feed_title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
{/* Sliding Ball Capsule Toggle Button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`capsule-switch-btn ${
|
|
||||||
selectedVariantKey === 'readability_full_text' ? 'active' : ''
|
|
||||||
} ${isScraping ? 'scraping' : ''}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isScraping) return;
|
|
||||||
if (selectedVariantKey === 'readability_full_text') {
|
|
||||||
setSelectedVariantKey('original');
|
|
||||||
} else {
|
|
||||||
if (hasReadability) {
|
|
||||||
setSelectedVariantKey('readability_full_text');
|
|
||||||
} else {
|
|
||||||
handleScrapeFullText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
|
||||||
selectedVariantKey === 'readability_full_text'
|
|
||||||
? 'Switch to Original RSS content'
|
|
||||||
: hasReadability
|
|
||||||
? 'Switch to Clean Full Text'
|
|
||||||
: 'Scrape and view Clean Full Text'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="capsule-switch-track">
|
|
||||||
<span className="capsule-switch-label">
|
|
||||||
{isScraping
|
|
||||||
? 'Scraping...'
|
|
||||||
: selectedVariantKey === 'readability_full_text'
|
|
||||||
? 'Full Text'
|
|
||||||
: 'Original'}
|
|
||||||
</span>
|
|
||||||
<div className="capsule-switch-thumb">
|
|
||||||
{isScraping ? (
|
|
||||||
<Loader2 size={12} className="spin" />
|
|
||||||
) : selectedVariantKey === 'readability_full_text' ? (
|
|
||||||
<Zap size={12} />
|
|
||||||
) : (
|
|
||||||
<FileText size={12} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasAISummary && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn-secondary ${showAISummary ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: showAISummary ? 'rgba(16, 185, 129, 0.35)' : 'transparent',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '3px 8px',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
background: showAISummary ? 'rgba(16, 185, 129, 0.12)' : 'transparent',
|
|
||||||
color: showAISummary ? '#34d399' : 'var(--text-muted)',
|
|
||||||
boxShadow: showAISummary ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 3,
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
const next = !showAISummary;
|
|
||||||
setShowAISummary(next);
|
|
||||||
if (next) {
|
|
||||||
setTimeout(() => {
|
|
||||||
summaryCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={showAISummary ? 'Hide AI Summary' : 'Show AI Summary'}
|
|
||||||
>
|
|
||||||
<Sparkles size={12} style={{ color: showAISummary ? '#34d399' : undefined }} />
|
|
||||||
<span>Summary</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasTTS && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn-secondary ${selectedVariantKey === 'tts' ? 'active' : ''}`}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '16px',
|
|
||||||
padding: '3px 8px',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
background: selectedVariantKey === 'tts' ? 'var(--card-bg)' : 'transparent',
|
|
||||||
color: selectedVariantKey === 'tts' ? '#10b981' : 'var(--text-muted)',
|
|
||||||
boxShadow: selectedVariantKey === 'tts' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
}}
|
|
||||||
onClick={() => setSelectedVariantKey('tts')}
|
|
||||||
title="Audio Stream"
|
|
||||||
>
|
|
||||||
<Volume2 size={12} style={{ marginRight: 3 }} />
|
|
||||||
Audio
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleStar(selectedEntry)}
|
|
||||||
title={selectedEntry.is_starred ? 'Unstar' : 'Star'}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
size={16}
|
|
||||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{
|
|
||||||
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
|
||||||
}}
|
|
||||||
onClick={() => onToggleRead(selectedEntry)}
|
|
||||||
title={selectedEntry.is_read ? 'Mark Unread' : 'Mark Read'}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-icon"
|
|
||||||
title="Share Article"
|
|
||||||
>
|
|
||||||
<Share2 size={15} />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={selectedEntry.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="btn-icon"
|
|
||||||
title="Open Original"
|
|
||||||
>
|
|
||||||
<ExternalLink size={15} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reading Body */}
|
|
||||||
<div className="mobile-post-reader-content" ref={readerScrollRef}>
|
|
||||||
<div className="reader-content-canvas" style={{ padding: 0 }}>
|
|
||||||
<div className="article-header">
|
|
||||||
<div className="article-header-meta">
|
|
||||||
<span className="article-feed-badge">
|
|
||||||
{selectedEntry.feed_title}
|
|
||||||
</span>
|
|
||||||
<span className="article-meta-divider">•</span>
|
|
||||||
<span className="article-meta-item">
|
|
||||||
<Clock size={12} />
|
|
||||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="article-meta-divider">•</span>
|
|
||||||
<span className="article-meta-item">{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
|
||||||
{selectedEntry.author && (
|
|
||||||
<>
|
|
||||||
<span className="article-meta-divider">•</span>
|
|
||||||
<span className="article-meta-item">
|
|
||||||
<UserIcon size={12} /> {selectedEntry.author}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="reader-headline">
|
|
||||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{selectedEntry.title}
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{getEntryTags(selectedEntry.variants).length > 0 && (
|
|
||||||
<div className="article-tags-row">
|
|
||||||
{getEntryTags(selectedEntry.variants).map((tag) => (
|
|
||||||
<span key={tag} className="variant-tag-chip">🏷️ {tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Summary Card if present and visible */}
|
|
||||||
{selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
|
|
||||||
<div
|
|
||||||
ref={summaryCardRef}
|
|
||||||
style={{
|
|
||||||
background: 'rgba(16, 185, 129, 0.05)',
|
|
||||||
border: '1px solid rgba(16, 185, 129, 0.22)',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: '1rem 1.15rem',
|
|
||||||
marginBottom: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.6rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#34d399', fontWeight: 600, fontSize: '0.82rem' }}>
|
|
||||||
<Sparkles size={15} />
|
|
||||||
<span>AI Summary & Takeaways</span>
|
|
||||||
{selectedEntry.variants.ai_summary.metadata?.model && (
|
|
||||||
<span style={{ fontSize: '0.68rem', padding: '1px 5px', borderRadius: 4, background: 'rgba(16, 185, 129, 0.1)', color: '#34d399', border: '1px solid rgba(16, 185, 129, 0.2)' }}>
|
|
||||||
{String(selectedEntry.variants.ai_summary.metadata.model)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon"
|
|
||||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
|
||||||
title="Copy Summary"
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedEntry.variants?.ai_summary?.text_value) {
|
|
||||||
navigator.clipboard.writeText(selectedEntry.variants.ai_summary.text_value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy size={13} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon"
|
|
||||||
style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}
|
|
||||||
title="Hide Summary"
|
|
||||||
onClick={() => setShowAISummary(false)}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: '0.75rem', padding: '0 2px' }}>✕</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '0.88rem',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: renderMarkdownText(selectedEntry.variants.ai_summary.text_value),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dynamic Content Variant Rendering */}
|
|
||||||
{selectedVariantKey === 'tts' && hasTTS ? (
|
|
||||||
<div className="audio-player-container" style={{ margin: '1.5rem 0', padding: '1rem', background: 'var(--bg-card-hover)', borderRadius: '12px' }}>
|
|
||||||
<audio
|
|
||||||
controls
|
|
||||||
src={`/api/entries/${selectedEntry.id}/variants/tts/stream`}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="reader-body"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: getRenderedContent(),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Category, Feed } from '../../types';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { X, Plus, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AddFeedModalProps {
|
||||||
|
categories: Category[];
|
||||||
|
onClose: () => void;
|
||||||
|
onFeedAdded: (feed: Feed) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
|
||||||
|
categories,
|
||||||
|
onClose,
|
||||||
|
onFeedAdded,
|
||||||
|
}) => {
|
||||||
|
const [feedUrl, setFeedUrl] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [iconUrl, setIconUrl] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState<string>('');
|
||||||
|
const [newCatTitle, setNewCatTitle] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!feedUrl.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let finalCatId: string | undefined = categoryId || undefined;
|
||||||
|
|
||||||
|
// If user typed a new category name
|
||||||
|
if (newCatTitle.trim()) {
|
||||||
|
const newCat = await api.createCategory(newCatTitle.trim());
|
||||||
|
finalCatId = newCat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFeed = await api.createFeed(
|
||||||
|
feedUrl.trim(),
|
||||||
|
finalCatId,
|
||||||
|
title.trim() || undefined,
|
||||||
|
iconUrl.trim() || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
onFeedAdded(newFeed);
|
||||||
|
onClose();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to add feed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">Add Subscription</h3>
|
||||||
|
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.65rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--danger-soft)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label">Feed or Website URL *</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="settings-input"
|
||||||
|
value={feedUrl}
|
||||||
|
onChange={(e) => setFeedUrl(e.target.value)}
|
||||||
|
placeholder="https://news.ycombinator.com/rss"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label">Custom Title (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Auto-detect from feed title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label">Custom Favicon URL (Optional)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="settings-input"
|
||||||
|
value={iconUrl}
|
||||||
|
onChange={(e) => setIconUrl(e.target.value)}
|
||||||
|
placeholder="Auto-detected from website domain if empty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label">Category / Folder</label>
|
||||||
|
<select
|
||||||
|
className="settings-select"
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">(No Category)</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label">Or Create New Category</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
value={newCatTitle}
|
||||||
|
onChange={(e) => setNewCatTitle(e.target.value)}
|
||||||
|
placeholder="e.g. AI & Tech"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn-secondary" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={loading || !feedUrl.trim()}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="spinning" /> Subscribing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus size={14} /> Subscribe
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import { SearchHistoryItem } from '../../types';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import {
|
||||||
|
Rss,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
HelpCircle,
|
||||||
|
LogOut,
|
||||||
|
History,
|
||||||
|
X,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
search: string;
|
||||||
|
onSearchChange: (val: string) => void;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
onRefreshAll: () => void;
|
||||||
|
onOpenAddFeed: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenShortcuts: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Header: React.FC<HeaderProps> = ({
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
isRefreshing,
|
||||||
|
onRefreshAll,
|
||||||
|
onOpenAddFeed,
|
||||||
|
onOpenSettings,
|
||||||
|
onOpenShortcuts,
|
||||||
|
onLogout,
|
||||||
|
}) => {
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [historyItems, setHistoryItems] = useState<SearchHistoryItem[]>([]);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||||
|
|
||||||
|
// Load search history when input is focused or query changes
|
||||||
|
const loadHistory = async (query?: string) => {
|
||||||
|
try {
|
||||||
|
const items = await api.getSearchHistory(query, 8);
|
||||||
|
setHistoryItems(items || []);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showDropdown) {
|
||||||
|
loadHistory(search);
|
||||||
|
}
|
||||||
|
}, [search, showDropdown]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
searchContainerRef.current &&
|
||||||
|
!searchContainerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global `/` shortcut for focusing search bar
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
e.key === '/' &&
|
||||||
|
document.activeElement?.tagName !== 'INPUT' &&
|
||||||
|
document.activeElement?.tagName !== 'TEXTAREA'
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
searchInputRef.current?.select();
|
||||||
|
setShowDropdown(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectKeyword = async (keyword: string) => {
|
||||||
|
onSearchChange(keyword);
|
||||||
|
setShowDropdown(false);
|
||||||
|
try {
|
||||||
|
await api.recordSearch(keyword);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDownInSearch = async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyItems.length > 0) {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % historyItems.length);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (historyItems.length > 0) {
|
||||||
|
setSelectedIndex((prev) => (prev <= 0 ? historyItems.length - 1 : prev - 1));
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && historyItems[selectedIndex]) {
|
||||||
|
handleSelectKeyword(historyItems[selectedIndex].keyword);
|
||||||
|
} else if (search.trim()) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
try {
|
||||||
|
await api.recordSearch(search.trim());
|
||||||
|
loadHistory();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteHistoryItem = async (e: React.MouseEvent, keyword: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.deleteSearchHistory(keyword);
|
||||||
|
setHistoryItems((prev) => prev.filter((item) => item.keyword !== keyword));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAllHistory = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.deleteSearchHistory();
|
||||||
|
setHistoryItems([]);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
{/* Leftmost: Brand Icon & Name */}
|
||||||
|
<div className="header-left">
|
||||||
|
<div className="brand-badge" title="RSSPipe Gateway">
|
||||||
|
<Rss className="brand-icon" />
|
||||||
|
<span>RSSPipe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Autocomplete-Enabled Search Bar */}
|
||||||
|
<div className="search-container" ref={searchContainerRef}>
|
||||||
|
<Search size={15} className="search-icon-inside" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
className="search-input"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
onSearchChange(e.target.value);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
if (!showDropdown) setShowDropdown(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
loadHistory(search);
|
||||||
|
setShowDropdown(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDownInSearch}
|
||||||
|
placeholder="Search feeds, titles, or authors..."
|
||||||
|
/>
|
||||||
|
{search ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '0.5rem',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onSearchChange('');
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="search-shortcut-badge">/</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Autocomplete History Dropdown */}
|
||||||
|
{showDropdown && historyItems.length > 0 && (
|
||||||
|
<div className="search-autocomplete-menu">
|
||||||
|
<div className="search-autocomplete-header">
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
<Clock size={12} /> Recent Searches
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearAllHistory}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{historyItems.map((item, idx) => (
|
||||||
|
<div
|
||||||
|
key={item.keyword}
|
||||||
|
className={`search-autocomplete-item ${idx === selectedIndex ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectKeyword(item.keyword)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem', minWidth: 0, flex: 1 }}>
|
||||||
|
<History size={13} color="var(--text-muted)" />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.keyword}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-autocomplete-delete-btn"
|
||||||
|
onClick={(e) => handleDeleteHistoryItem(e, item.keyword)}
|
||||||
|
title="Remove from history"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions & Settings */}
|
||||||
|
<div className="header-right">
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={onOpenAddFeed}
|
||||||
|
title="Add new subscription (A)"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>Add Feed</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onRefreshAll}
|
||||||
|
title="Refresh feeds (R)"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} className={isRefreshing ? 'spinning' : ''} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Settings Button */}
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
title="Settings & Subscriptions (3)"
|
||||||
|
>
|
||||||
|
<Settings size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onOpenShortcuts}
|
||||||
|
title="Keyboard shortcuts (?)"
|
||||||
|
>
|
||||||
|
<HelpCircle size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onLogout}
|
||||||
|
title="Sign Out"
|
||||||
|
style={{ color: 'var(--danger)' }}
|
||||||
|
>
|
||||||
|
<LogOut size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X, Keyboard } from 'lucide-react';
|
||||||
|
|
||||||
|
interface KeyboardShortcutsModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const shortcuts = [
|
||||||
|
{ key: 'j / ↓', desc: 'Navigate to Next article' },
|
||||||
|
{ key: 'k / ↑', desc: 'Navigate to Previous article' },
|
||||||
|
{ key: 's', desc: 'Star / Unstar selected article' },
|
||||||
|
{ key: 'm / r', desc: 'Toggle Read / Unread status' },
|
||||||
|
{ key: 'f', desc: 'Scrape clean full-text via Readability' },
|
||||||
|
{ key: 'o / v', desc: 'Open original article in new tab' },
|
||||||
|
{ key: '[', desc: 'Collapse / Expand Sidebar' },
|
||||||
|
{ key: 'V', desc: 'Toggle View Mode (3-Split ↔ Waterfall)' },
|
||||||
|
{ key: 'Shift + A', desc: 'Mark all items in view as read' },
|
||||||
|
{ key: '/', desc: 'Focus global search bar' },
|
||||||
|
{ key: 'R', desc: 'Refresh all feed subscriptions' },
|
||||||
|
{ key: 'A', desc: 'Open Add Feed modal' },
|
||||||
|
{ key: '?', desc: 'Show keyboard shortcuts cheat sheet' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Keyboard size={18} color="var(--accent-primary)" />
|
||||||
|
<h3 className="modal-title">Keyboard Shortcuts</h3>
|
||||||
|
</div>
|
||||||
|
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{shortcuts.map((sc, i) => (
|
||||||
|
<div key={i} className="shortcut-row">
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>{sc.desc}</span>
|
||||||
|
<span className="kbd-badge">{sc.key}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { User } from '../../types';
|
||||||
|
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoginViewProps {
|
||||||
|
onLoginSuccess: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||||
|
const [username, setUsername] = useState('admin');
|
||||||
|
const [password, setPassword] = useState('admin123456');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.login(username, password);
|
||||||
|
onLoginSuccess(res.user);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
width: '100vw',
|
||||||
|
background: 'var(--bg-app)',
|
||||||
|
padding: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
padding: '2.5rem',
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.35rem' }}>
|
||||||
|
<Rss size={28} color="var(--accent-primary)" />
|
||||||
|
<h1 style={{ fontSize: '1.45rem', fontWeight: 800, color: 'var(--text-primary)' }}>RSSPipe</h1>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '1.5rem' }}>
|
||||||
|
Minimalist Feed & Information Engine
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.65rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--danger-soft)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<UserIcon size={13} /> Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="settings-input"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-form-row">
|
||||||
|
<label className="settings-label" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<Lock size={13} /> Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="settings-input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ width: '100%', height: '38px', marginTop: '0.5rem', fontSize: '0.9rem' }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="spinning" /> Signing In...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Sign In</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
Default credentials: <code>admin</code> / <code>admin123456</code>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { api } from '../services/api';
|
import { api } from '../../services/api';
|
||||||
import { X, UploadCloud, Download, Loader2, CheckCircle2 } from 'lucide-react';
|
import { X, UploadCloud, Download, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface OPMLModalProps {
|
interface OPMLModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -56,61 +56,70 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>OPML Migration & Backup</h3>
|
<h3 className="modal-title">OPML Migration</h3>
|
||||||
<button className="btn-icon" onClick={onClose}>
|
<button className="btn-icon" style={{ border: 'none' }} onClick={onClose}>
|
||||||
<X size={18} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{error && <div className="alert-danger">{error}</div>}
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.65rem 0.85rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--danger-soft)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(52, 211, 153, 0.15)',
|
padding: '0.65rem 0.85rem',
|
||||||
border: '1px solid var(--success)',
|
|
||||||
color: 'var(--success)',
|
|
||||||
padding: '0.85rem',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
marginBottom: '1.25rem',
|
background: 'var(--success-soft)',
|
||||||
|
color: 'var(--success)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: '0.4rem',
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CheckCircle2 size={18} />
|
<CheckCircle2 size={16} />
|
||||||
<span>
|
<span>
|
||||||
Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated!
|
Imported {result.imported} new feeds ({result.total} parsed in OPML).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Import Section */}
|
{/* Import Dropzone */}
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
<div>
|
||||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
<div className="settings-label" style={{ marginBottom: '0.5rem' }}>
|
||||||
Import Subscriptions (from Miniflux / Google Reader / OPML)
|
Import Subscriptions (OPML 2.0 XML)
|
||||||
</h4>
|
</div>
|
||||||
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
|
||||||
Upload an OPML 2.0 XML file to seamlessly import all your feeds and categories.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
style={{
|
style={{
|
||||||
border: '2px dashed var(--border)',
|
border: '2px dashed var(--border-strong)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
padding: '2rem 1rem',
|
padding: '1.75rem 1rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: 'var(--bg-main)',
|
background: 'var(--bg-subtle)',
|
||||||
transition: 'all 0.15s ease',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -120,31 +129,21 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<UploadCloud
|
<UploadCloud size={32} color="var(--accent-primary)" style={{ margin: '0 auto 0.5rem' }} />
|
||||||
size={36}
|
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
style={{ color: 'var(--accent)', margin: '0 auto 0.5rem auto' }}
|
{file ? file.name : 'Click to select or drop .opml / .xml file'}
|
||||||
/>
|
|
||||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>
|
|
||||||
{file ? file.name : 'Click to browse or drag & drop OPML file'}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
|
||||||
Supports .opml or .xml
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{file && (
|
{file && (
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: '0.75rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button className="btn-primary" onClick={handleImport} disabled={loading}>
|
||||||
className="btn-primary"
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 size={16} className="animate-spin" /> Importing...
|
<Loader2 size={14} className="spinning" /> Importing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Import {file.name}</>
|
'Start Import'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,18 +151,25 @@ export const OPMLModal: React.FC<OPMLModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Section */}
|
{/* Export Section */}
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1.25rem' }}>
|
<div style={{ borderTop: '1px solid var(--border-subtle)', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
<div className="settings-label" style={{ marginBottom: '0.5rem' }}>
|
||||||
Export Subscriptions
|
Export Subscriptions
|
||||||
</h4>
|
</div>
|
||||||
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
|
||||||
Download a complete OPML backup of all your subscribed feeds and folders.
|
Download complete backup of all feeds and categories.
|
||||||
</p>
|
</p>
|
||||||
<button className="btn-secondary" onClick={handleExport}>
|
<button className="btn-secondary" onClick={handleExport}>
|
||||||
<Download size={16} /> Export OPML File
|
<Download size={14} style={{ marginRight: 4 }} />
|
||||||
|
Export OPML File
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn-secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FeedEntry, FilterState, Feed, Category } from '../../types';
|
||||||
|
import { formatDateOnly, stripHtml } from '../../utils';
|
||||||
|
import {
|
||||||
|
CheckCheck,
|
||||||
|
LayoutList,
|
||||||
|
AlignJustify,
|
||||||
|
Inbox,
|
||||||
|
Star,
|
||||||
|
Share2,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DesktopArticleListProps {
|
||||||
|
entries: FeedEntry[];
|
||||||
|
selectedEntryId: string | null;
|
||||||
|
onSelectEntry: (entry: FeedEntry) => void;
|
||||||
|
width: number;
|
||||||
|
listMode: 'compact' | 'rich';
|
||||||
|
onToggleListMode: (mode: 'compact' | 'rich') => void;
|
||||||
|
onMarkAllRead: () => void;
|
||||||
|
filter: FilterState;
|
||||||
|
onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
|
||||||
|
feeds?: Feed[];
|
||||||
|
categories?: Category[];
|
||||||
|
stats?: import('../../types').Stats | null;
|
||||||
|
totalEntries?: number;
|
||||||
|
onFilterChange?: (newFilter: Partial<FilterState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract keywords or tags from entry variants or metadata
|
||||||
|
const extractEntryList = (entry: FeedEntry, variantKey: string, metaKey: string): string[] => {
|
||||||
|
const v = entry.variants?.[variantKey]?.text_value;
|
||||||
|
if (v) {
|
||||||
|
return v.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
const val = entry.metadata?.[metaKey];
|
||||||
|
if (Array.isArray(val)) return val.map(String);
|
||||||
|
if (typeof val === 'string' && val) {
|
||||||
|
return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopArticleList: React.FC<DesktopArticleListProps> = ({
|
||||||
|
entries,
|
||||||
|
selectedEntryId,
|
||||||
|
onSelectEntry,
|
||||||
|
width,
|
||||||
|
listMode,
|
||||||
|
onToggleListMode,
|
||||||
|
onMarkAllRead,
|
||||||
|
filter,
|
||||||
|
onToggleStar,
|
||||||
|
feeds = [],
|
||||||
|
categories = [],
|
||||||
|
stats,
|
||||||
|
totalEntries = 0,
|
||||||
|
onFilterChange,
|
||||||
|
}) => {
|
||||||
|
const getFilterTitle = () => {
|
||||||
|
if (filter.search) return `Search: "${filter.search}"`;
|
||||||
|
if (filter.feedId) {
|
||||||
|
const activeFeed = feeds.find((f) => f.id === filter.feedId);
|
||||||
|
if (activeFeed) return activeFeed.title;
|
||||||
|
}
|
||||||
|
if (filter.categoryId) {
|
||||||
|
const activeCat = categories.find((c) => c.id === filter.categoryId);
|
||||||
|
if (activeCat) return activeCat.title;
|
||||||
|
}
|
||||||
|
if (filter.view === 'starred') return 'Starred Articles';
|
||||||
|
if (filter.view === 'all') return 'All Articles';
|
||||||
|
if (filter.view === 'shared') return 'Shared Articles';
|
||||||
|
return 'Inbox';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate accurate unread and total counts from feeds/categories/stats
|
||||||
|
const getFilterCounts = () => {
|
||||||
|
if (filter.feedId) {
|
||||||
|
const activeFeed = feeds.find((f) => f.id === filter.feedId);
|
||||||
|
return {
|
||||||
|
unread: activeFeed?.unread_count || 0,
|
||||||
|
total: activeFeed?.total_count || totalEntries || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (filter.categoryId) {
|
||||||
|
const catFeeds = feeds.filter((f) => f.category_id === filter.categoryId);
|
||||||
|
const unread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
|
||||||
|
const total = catFeeds.reduce((acc, f) => acc + (f.total_count || 0), 0);
|
||||||
|
return { unread, total: total || totalEntries || 0 };
|
||||||
|
}
|
||||||
|
if (filter.view === 'starred') {
|
||||||
|
return { unread: 0, total: stats?.total_starred || totalEntries || 0 };
|
||||||
|
}
|
||||||
|
if (filter.view === 'all') {
|
||||||
|
return { unread: stats?.total_unread || 0, total: totalEntries || 0 };
|
||||||
|
}
|
||||||
|
if (filter.view === 'shared') {
|
||||||
|
return { unread: 0, total: stats?.total_shared || totalEntries || 0 };
|
||||||
|
}
|
||||||
|
// 'inbox'
|
||||||
|
return { unread: stats?.total_unread || 0, total: stats?.total_unread || totalEntries || 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { unread: trueUnreadCount, total: trueTotalCount } = getFilterCounts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desktop-list-pane" style={{ width: `${width}px` }}>
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div className="list-header-bar">
|
||||||
|
<div className="list-title-group">
|
||||||
|
<span className="list-title-text" title={getFilterTitle()}>
|
||||||
|
{getFilterTitle()}
|
||||||
|
</span>
|
||||||
|
{filter.view === 'inbox' ? (
|
||||||
|
trueUnreadCount > 0 && <span className="badge badge-unread">{trueUnreadCount}</span>
|
||||||
|
) : filter.view === 'all' ? (
|
||||||
|
trueTotalCount > 0 && (
|
||||||
|
<span className="badge badge-unread" style={{ background: 'rgba(148, 163, 184, 0.15)', color: 'var(--text-secondary)' }}>
|
||||||
|
{trueTotalCount}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : filter.view === 'starred' ? (
|
||||||
|
trueTotalCount > 0 && (
|
||||||
|
<span className="badge badge-unread" style={{ background: 'rgba(234, 179, 8, 0.15)', color: 'var(--accent-star)' }}>
|
||||||
|
{trueTotalCount}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
{/* Unread / All Scope Switcher when browsing a specific feed/category */}
|
||||||
|
{(filter.feedId || filter.categoryId) && onFilterChange && (
|
||||||
|
<div className="list-scope-pill-group">
|
||||||
|
<button
|
||||||
|
className={`scope-pill-btn ${filter.view === 'inbox' ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ view: 'inbox' })}
|
||||||
|
title="Show unread articles"
|
||||||
|
>
|
||||||
|
Unread
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`scope-pill-btn ${filter.view === 'all' ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ view: 'all' })}
|
||||||
|
title="Show all articles"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trueUnreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onMarkAllRead}
|
||||||
|
title="Mark all as read in this view (Shift+A)"
|
||||||
|
>
|
||||||
|
<CheckCheck size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="list-mode-toggle">
|
||||||
|
<button
|
||||||
|
className={`list-mode-btn ${listMode === 'compact' ? 'active' : ''}`}
|
||||||
|
onClick={() => onToggleListMode('compact')}
|
||||||
|
title="Compact 2-line list"
|
||||||
|
>
|
||||||
|
<AlignJustify size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`list-mode-btn ${listMode === 'rich' ? 'active' : ''}`}
|
||||||
|
onClick={() => onToggleListMode('rich')}
|
||||||
|
title="Expanded detailed list"
|
||||||
|
>
|
||||||
|
<LayoutList size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Articles Column */}
|
||||||
|
<div className="desktop-articles-scroll">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<div className="reader-empty-state" style={{ height: '300px' }}>
|
||||||
|
<Inbox className="reader-empty-icon" />
|
||||||
|
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
|
||||||
|
{filter.view === 'inbox' && (filter.feedId || filter.categoryId)
|
||||||
|
? 'No unread articles in this feed'
|
||||||
|
: 'No articles found'}
|
||||||
|
</div>
|
||||||
|
{filter.view === 'inbox' && (filter.feedId || filter.categoryId) && onFilterChange && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: '0.75rem', fontSize: '0.8rem', padding: '0.35rem 0.75rem' }}
|
||||||
|
onClick={() => onFilterChange({ view: 'all' })}
|
||||||
|
>
|
||||||
|
Switch to All Articles
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
entries.map((entry) => {
|
||||||
|
const isSelected = selectedEntryId === entry.id;
|
||||||
|
const hasAi = Boolean(entry.variants?.['ai_summary']?.text_value);
|
||||||
|
const isShared = Boolean(entry.share_uuid);
|
||||||
|
|
||||||
|
// 1. Compact 2-Line Mode
|
||||||
|
if (listMode === 'compact') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`article-row-compact ${!entry.is_read ? 'unread' : ''} ${
|
||||||
|
isSelected ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectEntry(entry)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`unread-indicator-dot ${entry.is_read ? 'read' : ''}`}
|
||||||
|
/>
|
||||||
|
<div className="compact-content-col">
|
||||||
|
<div className="compact-title">{entry.title}</div>
|
||||||
|
<div className="compact-date">{formatDateOnly(entry.published_at)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compact-action-col">
|
||||||
|
{isShared && (
|
||||||
|
<span className="compact-icon-item" title="Publicly shared">
|
||||||
|
<Share2 size={13} color="var(--badge-ai)" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.is_starred && (
|
||||||
|
<button
|
||||||
|
className="compact-icon-btn"
|
||||||
|
onClick={(e) => onToggleStar(entry, e)}
|
||||||
|
title="Starred bookmark (Click to unstar)"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={13}
|
||||||
|
color="var(--star-gold)"
|
||||||
|
fill="var(--star-gold)"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Non-Compact (Expanded) Mode: Title, Description, Date + (Starred, Shared, AI), Keywords + Tags
|
||||||
|
const keywords = extractEntryList(entry, 'keywords', 'keywords');
|
||||||
|
const tags = extractEntryList(entry, 'tags', 'tags');
|
||||||
|
const hasChips = keywords.length > 0 || tags.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`article-card-rich ${!entry.is_read ? 'unread' : ''} ${
|
||||||
|
isSelected ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectEntry(entry)}
|
||||||
|
>
|
||||||
|
{/* Line 1: Title */}
|
||||||
|
<h3 className="card-title">{entry.title}</h3>
|
||||||
|
|
||||||
|
{/* Line 2: Description */}
|
||||||
|
<p className="card-snippet">
|
||||||
|
{stripHtml(entry.content || entry.raw_content, 140)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Line 3: Date (Left) & Starred, Shared, AI Summarized (Right Aligned) */}
|
||||||
|
<div className="card-meta-status-row">
|
||||||
|
<span className="card-date">{formatDateOnly(entry.published_at)}</span>
|
||||||
|
|
||||||
|
<div className="card-action-icons">
|
||||||
|
{/* Starred */}
|
||||||
|
{entry.is_starred && (
|
||||||
|
<button
|
||||||
|
className="compact-icon-btn"
|
||||||
|
onClick={(e) => onToggleStar(entry, e)}
|
||||||
|
title="Starred bookmark (Click to unstar)"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={13}
|
||||||
|
color="var(--star-gold)"
|
||||||
|
fill="var(--star-gold)"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shared */}
|
||||||
|
{isShared && (
|
||||||
|
<span className="compact-icon-item" title="Publicly shared">
|
||||||
|
<Share2 size={13} color="var(--badge-ai)" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Summarized */}
|
||||||
|
{hasAi && (
|
||||||
|
<span className="compact-icon-item" title="AI Summarized">
|
||||||
|
<Sparkles size={13} color="var(--badge-ai)" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line 4: Keywords & Tags (in different colors) */}
|
||||||
|
{hasChips && (
|
||||||
|
<div className="card-chips-row">
|
||||||
|
{keywords.map((kw, i) => (
|
||||||
|
<span key={`kw-${i}`} className="card-chip card-keyword-chip">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{tags.map((tg, i) => (
|
||||||
|
<span key={`tg-${i}`} className="card-chip card-tag-chip">
|
||||||
|
#{tg}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { FeedEntry } from '../../types';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { formatDateTime, estimateReadingTime, renderMarkdownText } from '../../utils';
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
FileText,
|
||||||
|
Clock,
|
||||||
|
User as UserIcon,
|
||||||
|
CheckCircle2,
|
||||||
|
Share2,
|
||||||
|
ChevronDown,
|
||||||
|
Headphones,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DesktopReaderProps {
|
||||||
|
entry: FeedEntry | null;
|
||||||
|
onToggleStar: (entry: FeedEntry) => void;
|
||||||
|
onToggleRead: (entry: FeedEntry) => void;
|
||||||
|
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||||
|
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||||
|
currentAudioUrl?: string | null;
|
||||||
|
isPlayingAudio?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopReader: React.FC<DesktopReaderProps> = ({
|
||||||
|
entry,
|
||||||
|
onToggleStar,
|
||||||
|
onToggleRead,
|
||||||
|
onUpdateEntry,
|
||||||
|
onPlayAudio,
|
||||||
|
currentAudioUrl,
|
||||||
|
isPlayingAudio,
|
||||||
|
}) => {
|
||||||
|
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||||
|
const [isScraping, setIsScraping] = useState(false);
|
||||||
|
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState<string | null>(null);
|
||||||
|
const [isSharing, setIsSharing] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [shareCopied, setShareCopied] = useState(false);
|
||||||
|
const [showActionDropdown, setShowActionDropdown] = useState(false);
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autoReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Helper to mark current entry as read if unread
|
||||||
|
const markAsReadIfUnread = () => {
|
||||||
|
if (entry && !entry.is_read) {
|
||||||
|
onToggleRead(entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Automatic read triggers
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoReadTimerRef.current) {
|
||||||
|
clearTimeout(autoReadTimerRef.current);
|
||||||
|
autoReadTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry || entry.is_read) return;
|
||||||
|
|
||||||
|
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
|
||||||
|
if (!isAutoReadEnabled) return;
|
||||||
|
|
||||||
|
// 1. If content <= 1 page on view, auto mark as read after 3 seconds
|
||||||
|
const measureTimer = setTimeout(() => {
|
||||||
|
const scrollEl = scrollAreaRef.current;
|
||||||
|
if (!scrollEl) return;
|
||||||
|
|
||||||
|
const isShortContent = scrollEl.scrollHeight <= scrollEl.clientHeight + 25;
|
||||||
|
if (isShortContent) {
|
||||||
|
autoReadTimerRef.current = setTimeout(() => {
|
||||||
|
markAsReadIfUnread();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(measureTimer);
|
||||||
|
if (autoReadTimerRef.current) {
|
||||||
|
clearTimeout(autoReadTimerRef.current);
|
||||||
|
autoReadTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [entry?.id, entry?.is_read, selectedVariantKey]);
|
||||||
|
|
||||||
|
// 2. If click to the content view (only content, not including the right pane toolbar)
|
||||||
|
const handleContentClick = () => {
|
||||||
|
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
|
||||||
|
if (!isAutoReadEnabled || !entry || entry.is_read) return;
|
||||||
|
markAsReadIfUnread();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. If content > 1 page on view, scroll on the right pane will auto mark as read
|
||||||
|
const handleContentScroll = () => {
|
||||||
|
const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
|
||||||
|
if (!isAutoReadEnabled || !entry || entry.is_read) return;
|
||||||
|
|
||||||
|
const scrollEl = scrollAreaRef.current;
|
||||||
|
if (!scrollEl) return;
|
||||||
|
|
||||||
|
const isLongContent = scrollEl.scrollHeight > scrollEl.clientHeight + 25;
|
||||||
|
if (isLongContent && scrollEl.scrollTop > 15) {
|
||||||
|
markAsReadIfUnread();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync selected variant and clear transient errors when switching articles
|
||||||
|
useEffect(() => {
|
||||||
|
setAiError(null);
|
||||||
|
if (entry?.variants?.['readability_full_text']) {
|
||||||
|
setSelectedVariantKey('readability_full_text');
|
||||||
|
} else {
|
||||||
|
setSelectedVariantKey('original');
|
||||||
|
}
|
||||||
|
}, [entry?.id]);
|
||||||
|
|
||||||
|
// Close dropdown on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowActionDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return (
|
||||||
|
<div className="desktop-reader-pane">
|
||||||
|
<div className="reader-empty-state">
|
||||||
|
<FileText className="reader-empty-icon" />
|
||||||
|
<div style={{ fontSize: '1.05rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||||
|
No Article Selected
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||||
|
Choose an article from the list or press <span className="kbd-badge">j</span> / <span className="kbd-badge">k</span> to navigate
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = entry.variants || {};
|
||||||
|
const hasReadability = Boolean(variants['readability_full_text']?.text_value);
|
||||||
|
const hasAISummary = Boolean(variants['ai_summary']?.text_value);
|
||||||
|
const ttsVariant = variants['tts'] || variants['podcast_tts'];
|
||||||
|
const isShared = Boolean(entry.share_uuid);
|
||||||
|
|
||||||
|
// Extract keywords / tags from variants or metadata
|
||||||
|
const extractList = (variantKey: string, metaKey: string): string[] => {
|
||||||
|
if (variants[variantKey]?.text_value) {
|
||||||
|
return variants[variantKey].text_value
|
||||||
|
.split(/[,,\n]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
const val = entry.metadata?.[metaKey];
|
||||||
|
if (Array.isArray(val)) return val.map(String);
|
||||||
|
if (typeof val === 'string' && val) {
|
||||||
|
return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const keywords = extractList('keywords', 'keywords');
|
||||||
|
const tags = extractList('tags', 'tags');
|
||||||
|
|
||||||
|
// Handle Scrape Full Text
|
||||||
|
const handleScrapeFullText = async () => {
|
||||||
|
if (!entry || isScraping) return;
|
||||||
|
setIsScraping(true);
|
||||||
|
try {
|
||||||
|
const updated = await api.scrapeEntry(entry.id);
|
||||||
|
onUpdateEntry?.(updated);
|
||||||
|
setSelectedVariantKey('readability_full_text');
|
||||||
|
} catch (err: any) {
|
||||||
|
setAiError(`Full text extraction failed: ${err.message || err}`);
|
||||||
|
} finally {
|
||||||
|
setIsScraping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Primary Split Action (Toggle between Full Text and Original)
|
||||||
|
const handlePrimaryActionClick = () => {
|
||||||
|
if (!hasReadability) {
|
||||||
|
handleScrapeFullText();
|
||||||
|
} else if (selectedVariantKey === 'readability_full_text') {
|
||||||
|
setSelectedVariantKey('original');
|
||||||
|
} else {
|
||||||
|
setSelectedVariantKey('readability_full_text');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle On-Demand AI Summary Generation
|
||||||
|
const handleGenerateAISummary = async () => {
|
||||||
|
setShowActionDropdown(false);
|
||||||
|
if (!entry || isGeneratingAI) return;
|
||||||
|
setIsGeneratingAI(true);
|
||||||
|
setAiError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.generateAISummary(entry.id);
|
||||||
|
onUpdateEntry?.(updated);
|
||||||
|
} catch (err: any) {
|
||||||
|
setAiError(err.message || 'AI generation failed. Please check AI settings.');
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingAI(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Toggle Public Share
|
||||||
|
const handleToggleShare = async () => {
|
||||||
|
if (!entry || isSharing) return;
|
||||||
|
setIsSharing(true);
|
||||||
|
try {
|
||||||
|
const res = await api.toggleShare(entry.id);
|
||||||
|
const updatedEntry = {
|
||||||
|
...res.entry,
|
||||||
|
share_uuid: res.share_uuid ?? null,
|
||||||
|
};
|
||||||
|
onUpdateEntry?.(updatedEntry);
|
||||||
|
if (res.shared && res.share_uuid) {
|
||||||
|
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
setShareCopied(true);
|
||||||
|
setTimeout(() => setShareCopied(false), 2500);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setAiError(`Sharing failed: ${err.message || err}`);
|
||||||
|
} finally {
|
||||||
|
setIsSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
if (!entry.url) return;
|
||||||
|
navigator.clipboard.writeText(entry.url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRenderedContent = () => {
|
||||||
|
if (selectedVariantKey === 'readability_full_text' && variants['readability_full_text']?.text_value) {
|
||||||
|
return variants['readability_full_text'].text_value;
|
||||||
|
}
|
||||||
|
return entry.content || entry.raw_content || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentAudioActive = Boolean(
|
||||||
|
ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="desktop-reader-pane">
|
||||||
|
{/* Top Action Toolbar */}
|
||||||
|
<div className="reader-toolbar">
|
||||||
|
{/* Left Toolbar Actions */}
|
||||||
|
<div className="reader-toolbar-left">
|
||||||
|
{/* 1. Toggle Read/Unread */}
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onToggleRead(entry)}
|
||||||
|
title={entry.is_read ? 'Mark as unread (m)' : 'Mark as read (m)'}
|
||||||
|
>
|
||||||
|
<CheckCircle2
|
||||||
|
size={15}
|
||||||
|
color={entry.is_read ? 'var(--text-dim)' : 'var(--accent-primary)'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 2. Toggle Star */}
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => onToggleStar(entry)}
|
||||||
|
title={entry.is_starred ? 'Unstar bookmark (s)' : 'Star bookmark (s)'}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={15}
|
||||||
|
color={entry.is_starred ? 'var(--star-gold)' : 'var(--text-muted)'}
|
||||||
|
fill={entry.is_starred ? 'var(--star-gold)' : 'none'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 3. Toggle Shared */}
|
||||||
|
<button
|
||||||
|
className={`btn-icon ${isShared ? 'active' : ''}`}
|
||||||
|
onClick={handleToggleShare}
|
||||||
|
disabled={isSharing}
|
||||||
|
title={isShared ? 'Publicly Shared (Click to unshare / copy link)' : 'Share public link'}
|
||||||
|
style={{ color: isShared ? 'var(--badge-ai)' : 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{isSharing ? (
|
||||||
|
<Loader2 size={15} className="spinning" />
|
||||||
|
) : (
|
||||||
|
<Share2 size={15} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 4. Copy Link to Pasteboard */}
|
||||||
|
<button className="btn-icon" onClick={handleCopyLink} title="Copy link to clipboard">
|
||||||
|
<Copy size={15} color={copied ? 'var(--success)' : 'var(--text-muted)'} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 5. Go to Original Link */}
|
||||||
|
<a
|
||||||
|
href={entry.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-icon"
|
||||||
|
title="Open original website in new tab (o)"
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Toolbar: Split Action Button & Actions Dropdown */}
|
||||||
|
<div className="reader-toolbar-right">
|
||||||
|
{shareCopied && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--success)', fontWeight: 600, marginRight: '0.25rem' }}>
|
||||||
|
✓ Share link copied!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="split-action-container" ref={dropdownRef}>
|
||||||
|
{/* Primary Action Button */}
|
||||||
|
<button
|
||||||
|
className="split-action-main-btn"
|
||||||
|
onClick={handlePrimaryActionClick}
|
||||||
|
disabled={isScraping}
|
||||||
|
title={
|
||||||
|
!hasReadability
|
||||||
|
? 'Scrape clean full-text via Readability'
|
||||||
|
: selectedVariantKey === 'readability_full_text'
|
||||||
|
? 'Switch back to original RSS feed'
|
||||||
|
: 'Switch to clean full-text'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isScraping ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={13} className="spinning" />
|
||||||
|
<span>Scraping...</span>
|
||||||
|
</>
|
||||||
|
) : !hasReadability ? (
|
||||||
|
<>
|
||||||
|
<Zap size={13} />
|
||||||
|
<span>Get Full Text</span>
|
||||||
|
</>
|
||||||
|
) : selectedVariantKey === 'readability_full_text' ? (
|
||||||
|
<>
|
||||||
|
<FileText size={13} />
|
||||||
|
<span>Original Feed</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap size={13} />
|
||||||
|
<span>Full Text (Clean)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Split Dropdown Trigger */}
|
||||||
|
<button
|
||||||
|
className="split-action-arrow-btn"
|
||||||
|
onClick={() => setShowActionDropdown(!showActionDropdown)}
|
||||||
|
title="More actions"
|
||||||
|
>
|
||||||
|
<ChevronDown size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu (Only AI Summary and Audio Generation) */}
|
||||||
|
{showActionDropdown && (
|
||||||
|
<div className="split-dropdown-menu">
|
||||||
|
{/* 1. Generate AI Summary */}
|
||||||
|
<button
|
||||||
|
className="split-dropdown-item"
|
||||||
|
onClick={handleGenerateAISummary}
|
||||||
|
disabled={isGeneratingAI}
|
||||||
|
>
|
||||||
|
{isGeneratingAI ? (
|
||||||
|
<Loader2 size={14} className="spinning" color="var(--badge-ai)" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={14} color="var(--badge-ai)" />
|
||||||
|
)}
|
||||||
|
<span>{isGeneratingAI ? 'Generating AI Summary...' : 'Generate AI Summary'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 2. Generate Audio (Placeholder) */}
|
||||||
|
<button
|
||||||
|
className="split-dropdown-item"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionDropdown(false);
|
||||||
|
alert('Audio narration synthesis is in development and will be available soon!');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Headphones size={14} color="var(--badge-tts)" />
|
||||||
|
<span>Generate Audio</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reader Scrollable Container (Full width, scrollbar tracks on the far right) */}
|
||||||
|
<div
|
||||||
|
className="reader-scroll-area"
|
||||||
|
ref={scrollAreaRef}
|
||||||
|
onScroll={handleContentScroll}
|
||||||
|
onClick={handleContentClick}
|
||||||
|
>
|
||||||
|
<div className="reader-content-inner">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="reader-header">
|
||||||
|
{/* 1. Secondary Title: Feed Title */}
|
||||||
|
<div className="reader-feed-subtitle" style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||||
|
{entry.feed_site_url && (
|
||||||
|
<img
|
||||||
|
src={`https://www.google.com/s2/favicons?domain=${entry.feed_site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`}
|
||||||
|
alt=""
|
||||||
|
style={{ width: '14px', height: '14px', borderRadius: '2px', objectFit: 'contain', flexShrink: 0 }}
|
||||||
|
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{entry.feed_title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Post Title */}
|
||||||
|
<h1 className="reader-article-title">{entry.title}</h1>
|
||||||
|
|
||||||
|
{/* 3. Author (if exists) */}
|
||||||
|
{entry.author && (
|
||||||
|
<div className="reader-meta-line">
|
||||||
|
<UserIcon size={14} className="reader-meta-icon" />
|
||||||
|
<span className="reader-author-name">{entry.author}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. Updated Date (YYYY-MM-DD HH:mm) & Time to Read */}
|
||||||
|
<div className="reader-meta-line">
|
||||||
|
<Clock size={14} className="reader-meta-icon" />
|
||||||
|
<span>{formatDateTime(entry.published_at)}</span>
|
||||||
|
<span className="reader-meta-dot">•</span>
|
||||||
|
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Keywords (if exists) */}
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<div className="reader-chips-row">
|
||||||
|
<span className="reader-chips-label">Keywords:</span>
|
||||||
|
<div className="reader-chips-list">
|
||||||
|
{keywords.map((kw, i) => (
|
||||||
|
<span key={i} className="reader-chip">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 6. Tags (if exists) */}
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="reader-chips-row">
|
||||||
|
<span className="reader-chips-label">Tags:</span>
|
||||||
|
<div className="reader-chips-list">
|
||||||
|
{tags.map((tg, i) => (
|
||||||
|
<span key={i} className="reader-chip reader-tag-chip">
|
||||||
|
#{tg}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7. Splitter Divider */}
|
||||||
|
<hr className="reader-header-divider" />
|
||||||
|
|
||||||
|
{/* Transient AI Error Message Block (In-page error on summary area) */}
|
||||||
|
{aiError && (
|
||||||
|
<div className="desktop-ai-summary-box ai-error-box">
|
||||||
|
<div className="ai-summary-header" style={{ color: 'var(--danger)', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>AI Action Notice</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAiError(null)}
|
||||||
|
style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
|
||||||
|
title="Dismiss error"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="ai-summary-body" style={{ color: 'var(--text-primary)', fontSize: '0.85rem' }}>
|
||||||
|
{aiError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 8. AI Executive Summary (if exists) */}
|
||||||
|
{hasAISummary && (
|
||||||
|
<div className="desktop-ai-summary-box">
|
||||||
|
<div className="ai-summary-header">
|
||||||
|
<Sparkles size={16} />
|
||||||
|
<span>AI Executive Summary</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="ai-summary-body"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderMarkdownText(variants['ai_summary'].text_value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 9. Audio Narration Bar (if exists) */}
|
||||||
|
{ttsVariant?.stream_url && (
|
||||||
|
<div className="desktop-inline-audio-bar">
|
||||||
|
<button
|
||||||
|
className="audio-play-circle-btn"
|
||||||
|
onClick={() => onPlayAudio?.(entry, ttsVariant.stream_url!)}
|
||||||
|
title="Play Narration Audio"
|
||||||
|
>
|
||||||
|
{isCurrentAudioActive ? <Pause size={18} /> : <Play size={18} style={{ marginLeft: 2 }} />}
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{ttsVariant.variant_key === 'podcast_tts' ? 'Podcast Audio Episode' : 'Voice Narration'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
Listen to synthesized audio for this article
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 10. Article Content Body */}
|
||||||
|
<div
|
||||||
|
className="reader-body-rendered"
|
||||||
|
dangerouslySetInnerHTML={{ __html: getRenderedContent() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Category, Feed, FilterState, Stats } from '../../types';
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
Star,
|
||||||
|
Layers,
|
||||||
|
Share2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Folder,
|
||||||
|
Rss,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DesktopSidebarProps {
|
||||||
|
filter: FilterState;
|
||||||
|
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||||
|
categories: Category[];
|
||||||
|
feeds: Feed[];
|
||||||
|
stats: Stats | null;
|
||||||
|
width: number;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenAddFeed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeedIcon: React.FC<{ feed: Feed; size?: number }> = ({ feed, size = 15 }) => {
|
||||||
|
const [imgError, setImgError] = useState(false);
|
||||||
|
const iconUrl =
|
||||||
|
feed.icon_url ||
|
||||||
|
(feed.site_url
|
||||||
|
? `https://www.google.com/s2/favicons?domain=${feed.site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`
|
||||||
|
: '');
|
||||||
|
|
||||||
|
if (iconUrl && !imgError) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={iconUrl}
|
||||||
|
alt=""
|
||||||
|
className="feed-favicon-img"
|
||||||
|
style={{
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '3px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Rss size={size} className="sidebar-item-icon" style={{ flexShrink: 0 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopSidebar: React.FC<DesktopSidebarProps> = ({
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
categories,
|
||||||
|
feeds,
|
||||||
|
stats,
|
||||||
|
width,
|
||||||
|
onOpenAddFeed,
|
||||||
|
}) => {
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const toggleCategory = (catId: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsedCategories((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[catId]: !prev[catId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedsByCategory = categories.map((cat) => ({
|
||||||
|
category: cat,
|
||||||
|
feeds: feeds.filter((f) => f.category_id === cat.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
|
||||||
|
|
||||||
|
// Handle feed selection with safe view resolution & search reset
|
||||||
|
const handleSelectFeed = (feed: Feed) => {
|
||||||
|
onFilterChange({
|
||||||
|
feedId: feed.id,
|
||||||
|
categoryId: undefined,
|
||||||
|
search: '',
|
||||||
|
view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle category selection with safe view resolution & search reset
|
||||||
|
const handleSelectCategory = (category: Category) => {
|
||||||
|
onFilterChange({
|
||||||
|
categoryId: category.id,
|
||||||
|
feedId: undefined,
|
||||||
|
search: '',
|
||||||
|
view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="desktop-sidebar"
|
||||||
|
style={{ width: `${width}px` }}
|
||||||
|
>
|
||||||
|
<div className="sidebar-scroll-area">
|
||||||
|
{/* 1. General Views */}
|
||||||
|
<div className="sidebar-section-title">Views</div>
|
||||||
|
<div className="sidebar-nav-list">
|
||||||
|
{/* Inbox */}
|
||||||
|
<button
|
||||||
|
className={`sidebar-item ${filter.view === 'inbox' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ view: 'inbox', feedId: undefined, categoryId: undefined, search: '' })}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<Inbox className="sidebar-item-icon" />
|
||||||
|
<span className="sidebar-item-title">Inbox</span>
|
||||||
|
</div>
|
||||||
|
{stats && stats.total_unread > 0 && (
|
||||||
|
<span className="badge badge-unread">{stats.total_unread}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Starred */}
|
||||||
|
<button
|
||||||
|
className={`sidebar-item ${filter.view === 'starred' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ view: 'starred', feedId: undefined, categoryId: undefined, search: '' })}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<Star className="sidebar-item-icon" />
|
||||||
|
<span className="sidebar-item-title">Starred</span>
|
||||||
|
</div>
|
||||||
|
{stats && stats.total_starred > 0 && (
|
||||||
|
<span className="badge badge-starred">{stats.total_starred}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* All Articles */}
|
||||||
|
<button
|
||||||
|
className={`sidebar-item ${filter.view === 'all' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ view: 'all', feedId: undefined, categoryId: undefined, search: '' })}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<Layers className="sidebar-item-icon" />
|
||||||
|
<span className="sidebar-item-title">All Articles</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Shared */}
|
||||||
|
<button
|
||||||
|
className={`sidebar-item ${filter.view === 'shared' && !filter.feedId && !filter.categoryId ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ view: 'shared', feedId: undefined, categoryId: undefined, search: '' })}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<Share2 className="sidebar-item-icon" />
|
||||||
|
<span className="sidebar-item-title">Shared</span>
|
||||||
|
</div>
|
||||||
|
{stats && (stats.total_shared || 0) > 0 && (
|
||||||
|
<span className="badge badge-unread">{stats.total_shared}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Feeds & Subscriptions */}
|
||||||
|
<div className="sidebar-section-title" style={{ marginTop: '1.15rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Feeds</span>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '20px', height: '20px', border: 'none', background: 'transparent' }}
|
||||||
|
onClick={onOpenAddFeed}
|
||||||
|
title="Add Feed (A)"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-nav-list">
|
||||||
|
{/* Categories */}
|
||||||
|
{feedsByCategory.map(({ category, feeds: catFeeds }) => {
|
||||||
|
const isCatCollapsed = Boolean(collapsedCategories[category.id]);
|
||||||
|
const isCatActive = filter.categoryId === category.id && !filter.feedId;
|
||||||
|
const catUnread = catFeeds.reduce((acc, f) => acc + (f.unread_count || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={category.id} className="sidebar-category-group">
|
||||||
|
{/* Category Header Row */}
|
||||||
|
<div
|
||||||
|
className={`sidebar-item sidebar-category-header ${isCatActive ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectCategory(category)}
|
||||||
|
title={category.title}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<Folder className="sidebar-item-icon" />
|
||||||
|
<span className="sidebar-item-title">{category.title}</span>
|
||||||
|
<span
|
||||||
|
className="category-chevron-btn"
|
||||||
|
onClick={(e) => toggleCategory(category.id, e)}
|
||||||
|
title={isCatCollapsed ? 'Expand folder' : 'Collapse folder'}
|
||||||
|
>
|
||||||
|
{isCatCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catUnread > 0 && (
|
||||||
|
<span className="badge badge-unread">{catUnread}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feeds in Category */}
|
||||||
|
{!isCatCollapsed && (
|
||||||
|
<div className="sidebar-category-children">
|
||||||
|
{catFeeds.map((feed) => {
|
||||||
|
const isFeedActive = filter.feedId === feed.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={feed.id}
|
||||||
|
className={`sidebar-item ${isFeedActive ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectFeed(feed)}
|
||||||
|
title={feed.title}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<FeedIcon feed={feed} size={15} />
|
||||||
|
<span className="sidebar-item-title">{feed.title}</span>
|
||||||
|
</div>
|
||||||
|
{feed.unread_count > 0 && (
|
||||||
|
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Uncategorized Feeds */}
|
||||||
|
{uncategorizedFeeds.map((feed) => {
|
||||||
|
const isFeedActive = filter.feedId === feed.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={feed.id}
|
||||||
|
className={`sidebar-item ${isFeedActive ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectFeed(feed)}
|
||||||
|
title={feed.title}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<FeedIcon feed={feed} size={15} />
|
||||||
|
<span className="sidebar-item-title">{feed.title}</span>
|
||||||
|
</div>
|
||||||
|
{feed.unread_count > 0 && (
|
||||||
|
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface ResizableSplitterProps {
|
||||||
|
onResize: (deltaX: number) => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizableSplitter: React.FC<ResizableSplitterProps> = ({
|
||||||
|
onResize,
|
||||||
|
onDoubleClick,
|
||||||
|
title = 'Drag to resize columns',
|
||||||
|
}) => {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startX, setStartX] = useState(0);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartX(e.clientX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const deltaX = e.clientX - startX;
|
||||||
|
if (deltaX !== 0) {
|
||||||
|
onResize(deltaX);
|
||||||
|
setStartX(e.clientX);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDragging, startX, onResize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`pane-splitter ${isDragging ? 'is-dragging' : ''}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
title={title}
|
||||||
|
/>
|
||||||
|
{isDragging && <div className="splitter-drag-overlay" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Category, Feed, FeedEntry, FilterState, Stats } from '../../types';
|
||||||
|
import { DesktopSidebar } from './DesktopSidebar';
|
||||||
|
import { DesktopArticleList } from './DesktopArticleList';
|
||||||
|
import { DesktopReader } from './DesktopReader';
|
||||||
|
import { ResizableSplitter } from './ResizableSplitter';
|
||||||
|
|
||||||
|
interface ThreePaneViewProps {
|
||||||
|
filter: FilterState;
|
||||||
|
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||||
|
categories: Category[];
|
||||||
|
feeds: Feed[];
|
||||||
|
entries: FeedEntry[];
|
||||||
|
selectedEntryId: string | null;
|
||||||
|
onSelectEntry: (entry: FeedEntry) => void;
|
||||||
|
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||||
|
onToggleRead: (entry: FeedEntry) => void;
|
||||||
|
onMarkAllRead: () => void;
|
||||||
|
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||||
|
stats: Stats | null;
|
||||||
|
totalEntries?: number;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenAddFeed: () => void;
|
||||||
|
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||||
|
currentAudioUrl?: string | null;
|
||||||
|
isPlayingAudio?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
categories,
|
||||||
|
feeds,
|
||||||
|
entries,
|
||||||
|
selectedEntryId,
|
||||||
|
onSelectEntry,
|
||||||
|
onToggleStar,
|
||||||
|
onToggleRead,
|
||||||
|
onMarkAllRead,
|
||||||
|
onUpdateEntry,
|
||||||
|
stats,
|
||||||
|
totalEntries = 0,
|
||||||
|
onOpenSettings,
|
||||||
|
onOpenAddFeed,
|
||||||
|
onPlayAudio,
|
||||||
|
currentAudioUrl,
|
||||||
|
isPlayingAudio,
|
||||||
|
}) => {
|
||||||
|
// Pane Widths with persistence
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
||||||
|
const saved = localStorage.getItem('rsspipe_sidebar_width');
|
||||||
|
return saved ? parseInt(saved, 10) : 240;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [listWidth, setListWidth] = useState<number>(() => {
|
||||||
|
const saved = localStorage.getItem('rsspipe_list_width');
|
||||||
|
return saved ? parseInt(saved, 10) : 380;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [listMode, setListMode] = useState<'compact' | 'rich'>(() => {
|
||||||
|
const saved = localStorage.getItem('rsspipe_desktop_list_mode');
|
||||||
|
return saved === 'rich' ? 'rich' : 'compact';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('rsspipe_sidebar_width', sidebarWidth.toString());
|
||||||
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('rsspipe_list_width', listWidth.toString());
|
||||||
|
}, [listWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('rsspipe_desktop_list_mode', listMode);
|
||||||
|
}, [listMode]);
|
||||||
|
|
||||||
|
const handleResizeSidebar = (deltaX: number) => {
|
||||||
|
setSidebarWidth((prev) => Math.max(180, Math.min(prev + deltaX, 420)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResizeList = (deltaX: number) => {
|
||||||
|
setListWidth((prev) => Math.max(260, Math.min(prev + deltaX, 600)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedEntry = entries.find((e) => e.id === selectedEntryId) || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="three-pane-container">
|
||||||
|
{/* Pane 1: Sidebar */}
|
||||||
|
<DesktopSidebar
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
categories={categories}
|
||||||
|
feeds={feeds}
|
||||||
|
stats={stats}
|
||||||
|
width={sidebarWidth}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
onOpenAddFeed={onOpenAddFeed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Splitter 1 */}
|
||||||
|
<ResizableSplitter
|
||||||
|
onResize={handleResizeSidebar}
|
||||||
|
title="Drag to resize sidebar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pane 2: Article List */}
|
||||||
|
<DesktopArticleList
|
||||||
|
entries={entries}
|
||||||
|
selectedEntryId={selectedEntryId}
|
||||||
|
onSelectEntry={onSelectEntry}
|
||||||
|
width={listWidth}
|
||||||
|
listMode={listMode}
|
||||||
|
onToggleListMode={setListMode}
|
||||||
|
onMarkAllRead={onMarkAllRead}
|
||||||
|
filter={filter}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
feeds={feeds}
|
||||||
|
categories={categories}
|
||||||
|
stats={stats}
|
||||||
|
totalEntries={totalEntries}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Splitter 2 */}
|
||||||
|
<ResizableSplitter
|
||||||
|
onResize={handleResizeList}
|
||||||
|
title="Drag to resize article list column"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pane 3: Detail Reader */}
|
||||||
|
<DesktopReader
|
||||||
|
entry={selectedEntry}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
onToggleRead={onToggleRead}
|
||||||
|
onUpdateEntry={onUpdateEntry}
|
||||||
|
onPlayAudio={onPlayAudio}
|
||||||
|
currentAudioUrl={currentAudioUrl}
|
||||||
|
isPlayingAudio={isPlayingAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { FeedEntry } from '../../types';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
Volume2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DockedAudioPlayerProps {
|
||||||
|
entry: FeedEntry | null;
|
||||||
|
streamUrl: string | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onTogglePlay: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DockedAudioPlayer: React.FC<DockedAudioPlayerProps> = ({
|
||||||
|
entry,
|
||||||
|
streamUrl,
|
||||||
|
isPlaying,
|
||||||
|
onTogglePlay,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!streamUrl) return;
|
||||||
|
if (!audioRef.current) {
|
||||||
|
audioRef.current = new Audio();
|
||||||
|
}
|
||||||
|
const audio = audioRef.current;
|
||||||
|
audio.src = streamUrl;
|
||||||
|
audio.playbackRate = playbackRate;
|
||||||
|
|
||||||
|
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||||
|
const updateDuration = () => setDuration(audio.duration || 0);
|
||||||
|
const handleEnded = () => onTogglePlay();
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', updateTime);
|
||||||
|
audio.addEventListener('loadedmetadata', updateDuration);
|
||||||
|
audio.addEventListener('ended', handleEnded);
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', updateTime);
|
||||||
|
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||||
|
audio.removeEventListener('ended', handleEnded);
|
||||||
|
audio.pause();
|
||||||
|
};
|
||||||
|
}, [streamUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
audioRef.current.pause();
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.playbackRate = playbackRate;
|
||||||
|
}
|
||||||
|
}, [playbackRate]);
|
||||||
|
|
||||||
|
if (!entry || !streamUrl) return null;
|
||||||
|
|
||||||
|
const handleSeek = (newTime: number) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = (seconds: number) => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
const nextTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration));
|
||||||
|
audioRef.current.currentTime = nextTime;
|
||||||
|
setCurrentTime(nextTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (secs: number) => {
|
||||||
|
if (isNaN(secs)) return '0:00';
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Docked Mini Player (Fixed above Bottom Navigation Bar) */}
|
||||||
|
<div className="docked-audio-player" onClick={() => setIsExpanded(true)}>
|
||||||
|
<div className="docked-progress-line" style={{ width: `${progressPercent}%` }} />
|
||||||
|
|
||||||
|
<div className="docked-audio-info">
|
||||||
|
<Volume2 size={18} color="var(--badge-tts)" style={{ flexShrink: 0 }} />
|
||||||
|
<div className="docked-audio-title">{entry.title}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="docked-audio-controls" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
|
||||||
|
onClick={() => handleSkip(-15)}
|
||||||
|
title="Rewind 15s"
|
||||||
|
>
|
||||||
|
<RotateCcw size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '32px', height: '32px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
title={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={16} /> : <Play size={16} style={{ marginLeft: 2 }} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '28px', height: '28px', border: 'none', background: 'transparent' }}
|
||||||
|
onClick={() => handleSkip(15)}
|
||||||
|
title="Forward 15s"
|
||||||
|
>
|
||||||
|
<RotateCw size={15} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '24px', height: '24px', border: 'none', background: 'transparent' }}
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close Player"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Full Player Sheet */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="audio-expanded-modal" onClick={() => setIsExpanded(false)}>
|
||||||
|
<div className="audio-expanded-card" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ border: 'none', background: 'transparent' }}
|
||||||
|
onClick={() => setIsExpanded(false)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={22} />
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-muted)' }}>
|
||||||
|
Now Playing
|
||||||
|
</span>
|
||||||
|
<div style={{ width: 22 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', margin: '0.5rem 0' }}>
|
||||||
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-primary)', marginBottom: '0.35rem' }}>
|
||||||
|
{entry.title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||||
|
{entry.feed_title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrubber */}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={(e) => handleSeek(parseFloat(e.target.value))}
|
||||||
|
className="audio-scrubber-slider"
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.2rem' }}>
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play Controls */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '1.5rem', margin: '0.5rem 0' }}>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '40px', height: '40px', border: 'none' }}
|
||||||
|
onClick={() => handleSkip(-15)}
|
||||||
|
>
|
||||||
|
<RotateCcw size={20} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '56px', height: '56px', borderRadius: 'var(--radius-full)', background: 'var(--accent-primary)', color: 'var(--text-inverse)' }}
|
||||||
|
onClick={onTogglePlay}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause size={26} /> : <Play size={26} style={{ marginLeft: 3 }} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: '40px', height: '40px', border: 'none' }}
|
||||||
|
onClick={() => handleSkip(15)}
|
||||||
|
>
|
||||||
|
<RotateCw size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed Selector */}
|
||||||
|
<div className="audio-speed-chips">
|
||||||
|
{[0.75, 1.0, 1.25, 1.5, 2.0].map((rate) => (
|
||||||
|
<button
|
||||||
|
key={rate}
|
||||||
|
className={`audio-speed-chip ${playbackRate === rate ? 'active' : ''}`}
|
||||||
|
onClick={() => setPlaybackRate(rate)}
|
||||||
|
>
|
||||||
|
{rate}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { Category, Feed } from '../../types';
|
||||||
|
import { Folder, Plus, ChevronRight } from 'lucide-react';
|
||||||
|
import { FeedIcon } from '../desktop/DesktopSidebar';
|
||||||
|
|
||||||
|
interface FeedsTabViewProps {
|
||||||
|
categories: Category[];
|
||||||
|
feeds: Feed[];
|
||||||
|
onSelectFeed: (feed: Feed) => void;
|
||||||
|
onOpenAddFeed: () => void;
|
||||||
|
savedScrollTop?: number;
|
||||||
|
onScrollPosChange?: (pos: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeedsTabView: React.FC<FeedsTabViewProps> = ({
|
||||||
|
categories,
|
||||||
|
feeds,
|
||||||
|
onSelectFeed,
|
||||||
|
onOpenAddFeed,
|
||||||
|
savedScrollTop = 0,
|
||||||
|
onScrollPosChange,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Restore scroll position once on initial mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && savedScrollTop > 0) {
|
||||||
|
containerRef.current.scrollTop = savedScrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFeedClick = (feed: Feed) => {
|
||||||
|
if (containerRef.current && onScrollPosChange) {
|
||||||
|
onScrollPosChange(containerRef.current.scrollTop);
|
||||||
|
}
|
||||||
|
onSelectFeed(feed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedsByCategory = categories.map((cat) => ({
|
||||||
|
category: cat,
|
||||||
|
feeds: feeds.filter((f) => f.category_id === cat.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
padding: '1rem',
|
||||||
|
paddingBottom: 'calc(var(--bottom-nav-height) + 1.5rem)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 800, color: 'var(--text-primary)' }}>Subscriptions</h2>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ height: '30px', padding: '0 0.65rem', fontSize: '0.78rem' }}
|
||||||
|
onClick={onOpenAddFeed}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>Add Feed</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{feedsByCategory.map(({ category, feeds: catFeeds }) => {
|
||||||
|
if (catFeeds.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={category.id} className="settings-section-card" style={{ padding: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.35rem 0.5rem', fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<Folder size={16} />
|
||||||
|
<span>{category.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '0.4rem' }}>
|
||||||
|
{catFeeds.map((feed) => (
|
||||||
|
<div
|
||||||
|
key={feed.id}
|
||||||
|
className="sidebar-item"
|
||||||
|
style={{ padding: '0.65rem 0.5rem' }}
|
||||||
|
onClick={() => handleFeedClick(feed)}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<FeedIcon feed={feed} size={16} />
|
||||||
|
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
{feed.unread_count > 0 && (
|
||||||
|
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={15} color="var(--text-dim)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{uncategorizedFeeds.length > 0 && (
|
||||||
|
<div className="settings-section-card" style={{ padding: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', padding: '0.35rem 0.5rem', fontWeight: 700, fontSize: '0.88rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<Folder size={16} />
|
||||||
|
<span>Uncategorized</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: '0.4rem' }}>
|
||||||
|
{uncategorizedFeeds.map((feed) => (
|
||||||
|
<div
|
||||||
|
key={feed.id}
|
||||||
|
className="sidebar-item"
|
||||||
|
style={{ padding: '0.65rem 0.5rem' }}
|
||||||
|
onClick={() => handleFeedClick(feed)}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<FeedIcon feed={feed} size={16} />
|
||||||
|
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
{feed.unread_count > 0 && (
|
||||||
|
<span className="badge badge-unread">{feed.unread_count}</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={15} color="var(--text-dim)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Feed, FeedEntry } from '../../types';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { formatDateTime, estimateReadingTime, renderMarkdownText } from '../../utils';
|
||||||
|
import { FeedIcon } from '../desktop/DesktopSidebar';
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
Star,
|
||||||
|
ExternalLink,
|
||||||
|
Share2,
|
||||||
|
Sparkles,
|
||||||
|
Volume2,
|
||||||
|
Pause,
|
||||||
|
ChevronUp,
|
||||||
|
FileText,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
CheckCircle2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface MobileArticleSheetProps {
|
||||||
|
entry: FeedEntry;
|
||||||
|
feeds?: Feed[];
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleStar: (entry: FeedEntry) => void;
|
||||||
|
onToggleRead?: (entry: FeedEntry) => void;
|
||||||
|
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||||
|
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||||
|
currentAudioUrl?: string | null;
|
||||||
|
isPlayingAudio?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileArticleSheet: React.FC<MobileArticleSheetProps> = ({
|
||||||
|
entry,
|
||||||
|
feeds = [],
|
||||||
|
onClose,
|
||||||
|
onToggleStar,
|
||||||
|
onUpdateEntry,
|
||||||
|
onPlayAudio,
|
||||||
|
currentAudioUrl,
|
||||||
|
isPlayingAudio,
|
||||||
|
}) => {
|
||||||
|
const [selectedVariantKey, setSelectedVariantKey] = useState<string>('original');
|
||||||
|
const [isScraping, setIsScraping] = useState(false);
|
||||||
|
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState<string | null>(null);
|
||||||
|
const [isSharing, setIsSharing] = useState(false);
|
||||||
|
const [showActionDropdown, setShowActionDropdown] = useState(false);
|
||||||
|
const [copiedSummary, setCopiedSummary] = useState(false);
|
||||||
|
const [shareCopiedNotice, setShareCopiedNotice] = useState(false);
|
||||||
|
|
||||||
|
// Left-Edge Interactive Swipe Gesture
|
||||||
|
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
||||||
|
const [touchDeltaX, setTouchDeltaX] = useState<number>(0);
|
||||||
|
const [isEdgeSwiping, setIsEdgeSwiping] = useState(false);
|
||||||
|
const [isDismissing, setIsDismissing] = useState(false);
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const feed = feeds.find((f) => f.id === entry.feed_id);
|
||||||
|
|
||||||
|
// Sync variant state on entry change
|
||||||
|
useEffect(() => {
|
||||||
|
setAiError(null);
|
||||||
|
if (entry.variants?.['readability_full_text']) {
|
||||||
|
setSelectedVariantKey('readability_full_text');
|
||||||
|
} else {
|
||||||
|
setSelectedVariantKey('original');
|
||||||
|
}
|
||||||
|
}, [entry.id]);
|
||||||
|
|
||||||
|
// Click outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setShowActionDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Safe clipboard helper
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).catch(() => {
|
||||||
|
fallbackCopy(text);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fallbackCopy(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackCopy = (text: string) => {
|
||||||
|
try {
|
||||||
|
const el = document.createElement('textarea');
|
||||||
|
el.value = text;
|
||||||
|
el.setAttribute('readonly', '');
|
||||||
|
el.style.position = 'absolute';
|
||||||
|
el.style.left = '-9999px';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(el);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Clipboard fallback copy error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Left-edge swipe to dismiss gesture handlers
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const startX = e.touches[0].clientX;
|
||||||
|
// Only activate if swipe originates from within 45px of the left edge
|
||||||
|
if (startX <= 45) {
|
||||||
|
setIsEdgeSwiping(true);
|
||||||
|
setTouchStartX(startX);
|
||||||
|
setTouchDeltaX(0);
|
||||||
|
} else {
|
||||||
|
setIsEdgeSwiping(false);
|
||||||
|
setTouchStartX(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (!isEdgeSwiping || touchStartX === null) return;
|
||||||
|
const currentX = e.touches[0].clientX;
|
||||||
|
const delta = currentX - touchStartX;
|
||||||
|
if (delta > 0) {
|
||||||
|
setTouchDeltaX(delta);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (isEdgeSwiping && touchDeltaX > 80) {
|
||||||
|
setIsDismissing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
setTouchDeltaX(0);
|
||||||
|
}
|
||||||
|
setIsEdgeSwiping(false);
|
||||||
|
setTouchStartX(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full Text Scraper
|
||||||
|
const handleScrapeFullText = async () => {
|
||||||
|
if (isScraping) return;
|
||||||
|
setIsScraping(true);
|
||||||
|
setAiError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.scrapeEntry(entry.id);
|
||||||
|
onUpdateEntry?.(updated);
|
||||||
|
setSelectedVariantKey('readability_full_text');
|
||||||
|
} catch (err: any) {
|
||||||
|
setAiError(`Full text extraction failed: ${err?.message || err}`);
|
||||||
|
} finally {
|
||||||
|
setIsScraping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Primary Action Button (Toggle Full Text vs Original)
|
||||||
|
const handlePrimaryActionClick = () => {
|
||||||
|
if (!entry.variants?.['readability_full_text']?.text_value) {
|
||||||
|
handleScrapeFullText();
|
||||||
|
} else if (selectedVariantKey === 'readability_full_text') {
|
||||||
|
setSelectedVariantKey('original');
|
||||||
|
} else {
|
||||||
|
setSelectedVariantKey('readability_full_text');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate AI Summary
|
||||||
|
const handleGenerateAISummary = async () => {
|
||||||
|
setShowActionDropdown(false);
|
||||||
|
if (isGeneratingAI) return;
|
||||||
|
setIsGeneratingAI(true);
|
||||||
|
setAiError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.generateAISummary(entry.id);
|
||||||
|
onUpdateEntry?.(updated);
|
||||||
|
} catch (err: any) {
|
||||||
|
setAiError(err?.message || 'AI generation failed. Please check AI settings.');
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingAI(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle Public Share
|
||||||
|
const handleToggleShare = async () => {
|
||||||
|
if (isSharing || !entry?.id) return;
|
||||||
|
setIsSharing(true);
|
||||||
|
setAiError(null);
|
||||||
|
try {
|
||||||
|
const res = await api.toggleShare(entry.id);
|
||||||
|
if (res && res.entry) {
|
||||||
|
const updatedEntry = {
|
||||||
|
...res.entry,
|
||||||
|
share_uuid: res.share_uuid ?? null,
|
||||||
|
};
|
||||||
|
onUpdateEntry?.(updatedEntry);
|
||||||
|
}
|
||||||
|
if (res && res.shared && res.share_uuid) {
|
||||||
|
const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
|
||||||
|
copyToClipboard(shareUrl);
|
||||||
|
setShareCopiedNotice(true);
|
||||||
|
setTimeout(() => setShareCopiedNotice(false), 2500);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setAiError(`Sharing failed: ${err?.message || err}`);
|
||||||
|
} finally {
|
||||||
|
setIsSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = entry.variants || {};
|
||||||
|
const hasReadability = Boolean(variants['readability_full_text']?.text_value);
|
||||||
|
const hasAISummary = Boolean(variants['ai_summary']?.text_value);
|
||||||
|
const ttsVariant = variants['tts'] || variants['podcast_tts'];
|
||||||
|
const isShared = Boolean(entry.share_uuid);
|
||||||
|
|
||||||
|
const isCurrentAudioActive = Boolean(
|
||||||
|
ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
|
||||||
|
);
|
||||||
|
|
||||||
|
const getArticleBody = () => {
|
||||||
|
if (selectedVariantKey === 'readability_full_text' && variants['readability_full_text']?.text_value) {
|
||||||
|
return variants['readability_full_text'].text_value;
|
||||||
|
}
|
||||||
|
return entry.content || entry.raw_content || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopySummary = () => {
|
||||||
|
if (!variants['ai_summary']?.text_value) return;
|
||||||
|
copyToClipboard(variants['ai_summary'].text_value);
|
||||||
|
setCopiedSummary(true);
|
||||||
|
setTimeout(() => setCopiedSummary(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mobile-article-sheet ${!isDismissing && touchDeltaX === 0 ? 'slide-in' : ''}`}
|
||||||
|
style={{
|
||||||
|
transform: isDismissing
|
||||||
|
? 'translateX(100%)'
|
||||||
|
: touchDeltaX > 0
|
||||||
|
? `translateX(${touchDeltaX}px)`
|
||||||
|
: undefined,
|
||||||
|
transition: isEdgeSwiping ? 'none' : 'transform 0.22s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||||
|
}}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* 1. Top Single-Row Header: Return Button + Feed Favicon + Feed Title */}
|
||||||
|
<div className="mobile-sheet-top-nav">
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ border: 'none', background: 'transparent', padding: '0.25rem', marginLeft: '-0.35rem' }}
|
||||||
|
onClick={onClose}
|
||||||
|
title="Back to Feed"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={24} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.45rem',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feed ? (
|
||||||
|
<FeedIcon feed={feed} size={16} />
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '0.85rem' }}>📰</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.88rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.feed_title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Copied Banner Notice */}
|
||||||
|
{shareCopiedNotice && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'rgba(37, 99, 235, 0.1)',
|
||||||
|
color: '#2563eb',
|
||||||
|
padding: '0.45rem 1rem',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.4rem',
|
||||||
|
borderBottom: '1px solid rgba(37, 99, 235, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
<span>Public share link copied to clipboard!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable Article Body */}
|
||||||
|
<div className="mobile-sheet-scroll">
|
||||||
|
<div className="reader-meta-header" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
{/* Post Title */}
|
||||||
|
<h1 className="reader-post-title" style={{ fontSize: '1.45rem', marginTop: 0 }}>
|
||||||
|
{entry.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Author, Published Date & Reading Time */}
|
||||||
|
<div className="reader-meta-row" style={{ marginTop: '0.5rem' }}>
|
||||||
|
<span>by {entry.author || 'Unknown'}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{formatDateTime(entry.published_at)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transient Error Notice Banner */}
|
||||||
|
{aiError && (
|
||||||
|
<div
|
||||||
|
className="reader-error-banner"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0.65rem 0.85rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--danger-soft)',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{aiError}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAiError(null)}
|
||||||
|
style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Summary Box (Purple Accent) */}
|
||||||
|
{hasAISummary && (
|
||||||
|
<div className="desktop-ai-summary-box" style={{ margin: '1rem 0 1.5rem', borderColor: 'rgba(124, 58, 237, 0.3)' }}>
|
||||||
|
<div className="ai-summary-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<Sparkles size={15} color="#7c3aed" />
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '0.88rem', color: '#7c3aed' }}>AI Summary</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="ai-summary-copy-btn"
|
||||||
|
onClick={handleCopySummary}
|
||||||
|
title="Copy AI summary"
|
||||||
|
>
|
||||||
|
{copiedSummary ? <CheckCircle2 size={13} color="var(--success)" /> : <Copy size={13} />}
|
||||||
|
<span>{copiedSummary ? 'Copied' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="ai-summary-body"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderMarkdownText(variants['ai_summary'].text_value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rendered Article Content */}
|
||||||
|
<div
|
||||||
|
className="reader-body-rendered"
|
||||||
|
dangerouslySetInnerHTML={{ __html: getArticleBody() }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Docked Bottom Action Toolbar */}
|
||||||
|
<div className="mobile-sheet-bottom-bar">
|
||||||
|
{/* Left Bottom Corner: Star, Share, Audio, Original Link */}
|
||||||
|
<div className="mobile-sheet-bottom-left">
|
||||||
|
{/* Star Button (Yellow fill when active) */}
|
||||||
|
<button
|
||||||
|
className={`sheet-action-btn ${entry.is_starred ? 'starred' : ''}`}
|
||||||
|
onClick={() => onToggleStar(entry)}
|
||||||
|
title={entry.is_starred ? 'Unstar' : 'Star article'}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
size={19}
|
||||||
|
color={entry.is_starred ? '#d97706' : 'var(--text-muted)'}
|
||||||
|
fill={entry.is_starred ? '#d97706' : 'none'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Share Button (Blue icon + fill when shared) */}
|
||||||
|
<button
|
||||||
|
className={`sheet-action-btn ${isShared ? 'shared' : ''}`}
|
||||||
|
onClick={handleToggleShare}
|
||||||
|
disabled={isSharing}
|
||||||
|
title={isShared ? 'Shared publicly (click to toggle)' : 'Share article'}
|
||||||
|
>
|
||||||
|
{isSharing ? (
|
||||||
|
<Loader2 size={18} className="spinning" />
|
||||||
|
) : (
|
||||||
|
<Share2
|
||||||
|
size={18}
|
||||||
|
color={isShared ? '#2563eb' : 'var(--text-muted)'}
|
||||||
|
fill={isShared ? '#2563eb' : 'none'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Audio / TTS Button (Green/Emerald when active) */}
|
||||||
|
{ttsVariant?.stream_url && (
|
||||||
|
<button
|
||||||
|
className={`sheet-action-btn ${isCurrentAudioActive ? 'audio-active' : ''}`}
|
||||||
|
onClick={() => onPlayAudio?.(entry, ttsVariant.stream_url!)}
|
||||||
|
title="Play Audio"
|
||||||
|
>
|
||||||
|
{isCurrentAudioActive ? (
|
||||||
|
<Pause size={18} color="#059669" />
|
||||||
|
) : (
|
||||||
|
<Volume2 size={18} color="#059669" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Original Link Button */}
|
||||||
|
<a
|
||||||
|
href={entry.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sheet-action-btn"
|
||||||
|
title="Open original webpage"
|
||||||
|
>
|
||||||
|
<ExternalLink size={18} color="var(--text-muted)" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Bottom Corner: Popup Split Action Button */}
|
||||||
|
<div className="mobile-sheet-bottom-right" ref={dropdownRef}>
|
||||||
|
<div className="split-action-container">
|
||||||
|
<button
|
||||||
|
className="split-action-main-btn"
|
||||||
|
onClick={handlePrimaryActionClick}
|
||||||
|
disabled={isScraping}
|
||||||
|
title={
|
||||||
|
!hasReadability
|
||||||
|
? 'Extract full text using Readability'
|
||||||
|
: selectedVariantKey === 'readability_full_text'
|
||||||
|
? 'Show original feed content'
|
||||||
|
: 'Show full text'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isScraping ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={13} className="spinning" />
|
||||||
|
<span>Scraping...</span>
|
||||||
|
</>
|
||||||
|
) : !hasReadability ? (
|
||||||
|
<>
|
||||||
|
<Zap size={13} color="var(--accent-primary)" />
|
||||||
|
<span>Get Full Text</span>
|
||||||
|
</>
|
||||||
|
) : selectedVariantKey === 'readability_full_text' ? (
|
||||||
|
<>
|
||||||
|
<FileText size={13} color="var(--accent-primary)" />
|
||||||
|
<span>Original</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap size={13} color="var(--accent-primary)" />
|
||||||
|
<span>Full Text</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="split-action-arrow-btn"
|
||||||
|
onClick={() => setShowActionDropdown((prev) => !prev)}
|
||||||
|
title="More Actions"
|
||||||
|
>
|
||||||
|
<ChevronUp size={13} style={{ transform: showActionDropdown ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Popup Upwards Menu (Dropup) */}
|
||||||
|
{showActionDropdown && (
|
||||||
|
<div className="split-dropdown-menu dropup-menu">
|
||||||
|
<button
|
||||||
|
className="split-dropdown-item"
|
||||||
|
onClick={handleGenerateAISummary}
|
||||||
|
disabled={isGeneratingAI}
|
||||||
|
>
|
||||||
|
{isGeneratingAI ? (
|
||||||
|
<Loader2 size={14} className="spinning" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={14} color="#7c3aed" />
|
||||||
|
)}
|
||||||
|
<span>Generate AI Summary</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="split-dropdown-item"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionDropdown(false);
|
||||||
|
if (ttsVariant?.stream_url) {
|
||||||
|
onPlayAudio?.(entry, ttsVariant.stream_url);
|
||||||
|
} else {
|
||||||
|
setAiError('Audio generation is queued by backend rule.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Volume2 size={14} color="#059669" />
|
||||||
|
<span>Generate Audio (TTS)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { getSmartArtTheme, stripHtml } from '../../utils';
|
||||||
|
|
||||||
|
interface SmartArtCardProps {
|
||||||
|
title: string;
|
||||||
|
seed: string;
|
||||||
|
excerpt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SmartArtCard: React.FC<SmartArtCardProps> = ({ title, seed, excerpt }) => {
|
||||||
|
const theme = getSmartArtTheme(seed);
|
||||||
|
// Clean description text without HTML tags or raw URLs
|
||||||
|
const cleanExcerpt = excerpt ? stripHtml(excerpt, 130) : '';
|
||||||
|
const textToShow = cleanExcerpt.length > 15 ? cleanExcerpt : title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="smart-art-cover"
|
||||||
|
style={{
|
||||||
|
background: theme.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="smart-art-quote" style={{ color: theme.text }}>
|
||||||
|
{textToShow}
|
||||||
|
</div>
|
||||||
|
<div className="smart-art-accent-dot" style={{ background: theme.accent }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { Feed, FeedEntry } from '../../types';
|
||||||
|
import { Star, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StarredTabViewProps {
|
||||||
|
entries: FeedEntry[];
|
||||||
|
feeds: Feed[];
|
||||||
|
onSelectFeedFilter: (feedId?: string) => void;
|
||||||
|
onOpenAllStarred: () => void;
|
||||||
|
savedScrollTop?: number;
|
||||||
|
onScrollPosChange?: (pos: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StarredTabView: React.FC<StarredTabViewProps> = ({
|
||||||
|
entries,
|
||||||
|
feeds,
|
||||||
|
onSelectFeedFilter,
|
||||||
|
onOpenAllStarred,
|
||||||
|
savedScrollTop = 0,
|
||||||
|
onScrollPosChange,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Restore scroll position on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && savedScrollTop > 0) {
|
||||||
|
containerRef.current.scrollTop = savedScrollTop;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAllStarredClick = () => {
|
||||||
|
if (containerRef.current && onScrollPosChange) {
|
||||||
|
onScrollPosChange(containerRef.current.scrollTop);
|
||||||
|
}
|
||||||
|
onOpenAllStarred();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFeedClick = (feedId: string) => {
|
||||||
|
if (containerRef.current && onScrollPosChange) {
|
||||||
|
onScrollPosChange(containerRef.current.scrollTop);
|
||||||
|
}
|
||||||
|
onSelectFeedFilter(feedId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const starredEntries = entries.filter((e) => e.is_starred);
|
||||||
|
|
||||||
|
// Group starred count by feed
|
||||||
|
const starredCountsByFeed: Record<string, number> = {};
|
||||||
|
starredEntries.forEach((e) => {
|
||||||
|
starredCountsByFeed[e.feed_id] = (starredCountsByFeed[e.feed_id] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedsWithStarred = feeds.filter((f) => starredCountsByFeed[f.id] > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
padding: '1rem',
|
||||||
|
paddingBottom: 'calc(var(--bottom-nav-height) + 1.5rem)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.2rem', fontWeight: 800, color: 'var(--text-primary)' }}>Starred Items</h2>
|
||||||
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||||
|
{starredEntries.length} saved articles across {feedsWithStarred.length} sources
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section-card" style={{ padding: '0.5rem' }}>
|
||||||
|
<div
|
||||||
|
className="sidebar-item"
|
||||||
|
style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }}
|
||||||
|
onClick={handleAllStarredClick}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<Star size={18} color="var(--star-gold)" fill="var(--star-gold)" />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem', color: 'var(--text-primary)' }}>
|
||||||
|
All Starred Articles
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<span className="badge badge-starred">{starredEntries.length}</span>
|
||||||
|
<ChevronRight size={16} color="var(--text-dim)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedsWithStarred.map((feed) => (
|
||||||
|
<div
|
||||||
|
key={feed.id}
|
||||||
|
className="sidebar-item"
|
||||||
|
style={{ padding: '0.75rem 0.6rem', borderBottom: '1px solid var(--border-light)' }}
|
||||||
|
onClick={() => handleFeedClick(feed.id)}
|
||||||
|
>
|
||||||
|
<div className="sidebar-item-left">
|
||||||
|
<span style={{ fontSize: '0.88rem', color: 'var(--text-primary)' }}>{feed.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||||
|
<span className="badge badge-starred">{starredCountsByFeed[feed.id]}</span>
|
||||||
|
<ChevronRight size={16} color="var(--text-dim)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewPreference } from '../../types';
|
||||||
|
import { extractFirstImage, formatRelativeDate } from '../../utils';
|
||||||
|
import { XHSBottomNav, MobileTab } from './XHSBottomNav';
|
||||||
|
import { SmartArtCard } from './SmartArtCard';
|
||||||
|
import { MobileArticleSheet } from './MobileArticleSheet';
|
||||||
|
import { DockedAudioPlayer } from './DockedAudioPlayer';
|
||||||
|
import { FeedsTabView } from './FeedsTabView';
|
||||||
|
import { StarredTabView } from './StarredTabView';
|
||||||
|
import { SettingsView } from '../settings/SettingsView';
|
||||||
|
import {
|
||||||
|
Headphones,
|
||||||
|
Sparkles,
|
||||||
|
ArrowLeft,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface WaterfallViewProps {
|
||||||
|
filter: FilterState;
|
||||||
|
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||||
|
categories: Category[];
|
||||||
|
feeds: Feed[];
|
||||||
|
entries: FeedEntry[];
|
||||||
|
stats: Stats | null;
|
||||||
|
currentUser: User;
|
||||||
|
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||||
|
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||||
|
onUpdateEntry?: (entry: FeedEntry) => void;
|
||||||
|
onRefreshData: () => void;
|
||||||
|
onOpenAddFeed: () => void;
|
||||||
|
onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
|
||||||
|
currentAudioUrl?: string | null;
|
||||||
|
isPlayingAudio?: boolean;
|
||||||
|
onTogglePlayAudio?: () => void;
|
||||||
|
onCloseAudio?: () => void;
|
||||||
|
playingEntry?: FeedEntry | null;
|
||||||
|
viewPreference: ViewPreference;
|
||||||
|
onChangeViewPreference: (pref: ViewPreference) => void;
|
||||||
|
onSwitchToDesktop?: () => void;
|
||||||
|
hasMore?: boolean;
|
||||||
|
isLoadingMore?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
categories,
|
||||||
|
feeds,
|
||||||
|
entries,
|
||||||
|
stats,
|
||||||
|
currentUser,
|
||||||
|
onToggleRead,
|
||||||
|
onToggleStar,
|
||||||
|
onUpdateEntry,
|
||||||
|
onRefreshData,
|
||||||
|
onOpenAddFeed,
|
||||||
|
onPlayAudio,
|
||||||
|
currentAudioUrl,
|
||||||
|
isPlayingAudio,
|
||||||
|
onTogglePlayAudio,
|
||||||
|
onCloseAudio,
|
||||||
|
playingEntry,
|
||||||
|
viewPreference,
|
||||||
|
onChangeViewPreference,
|
||||||
|
hasMore = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
onLoadMore,
|
||||||
|
}) => {
|
||||||
|
const [mobileTab, setMobileTab] = useState<MobileTab>('home');
|
||||||
|
const [parentTab, setParentTab] = useState<MobileTab>('home');
|
||||||
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||||
|
const selectedEntry = entries.find((e) => e.id === selectedEntryId) || null;
|
||||||
|
const [shuffledSeed, setShuffledSeed] = useState<number>(0);
|
||||||
|
|
||||||
|
// Tab Scroll Position Retention
|
||||||
|
const [feedsScrollPos, setFeedsScrollPos] = useState<number>(0);
|
||||||
|
const [starredScrollPos, setStarredScrollPos] = useState<number>(0);
|
||||||
|
|
||||||
|
// Search Bar Expand State
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState(filter.search || '');
|
||||||
|
|
||||||
|
// Pull-to-Refresh & Edge Gesture State
|
||||||
|
const [pullDistance, setPullDistance] = useState<number>(0);
|
||||||
|
const [isPullRefreshing, setIsPullRefreshing] = useState<boolean>(false);
|
||||||
|
const [edgeDeltaX, setEdgeDeltaX] = useState<number>(0);
|
||||||
|
const [isFeedDismissing, setIsFeedDismissing] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const touchStartY = useRef<number | null>(null);
|
||||||
|
const edgeSwipeRef = useRef<{ startX: number; startY: number } | null>(null);
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const pullThreshold = Math.min(window.innerHeight / 3, 110);
|
||||||
|
const currentFeedFilter = feeds.find((f) => f.id === filter.feedId);
|
||||||
|
|
||||||
|
const handleCardClick = (entry: FeedEntry) => {
|
||||||
|
setSelectedEntryId(entry.id);
|
||||||
|
if (!entry.is_read) {
|
||||||
|
onToggleRead(entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return to the originating tab when exiting a feed's waterfall view
|
||||||
|
const handleReturnFromFeed = () => {
|
||||||
|
onFilterChange({ feedId: undefined });
|
||||||
|
setMobileTab(parentTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Double Tap Home to Scroll to Top
|
||||||
|
const handleDoubleTapHome = () => {
|
||||||
|
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch Event Handlers (Pull-to-refresh + Left Edge Swipe to Return)
|
||||||
|
const handleTouchStart = (e: React.TouchEvent | React.MouseEvent) => {
|
||||||
|
if ('touches' in e && e.touches.length === 1 && currentFeedFilter) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
if (touch.clientX < 45) {
|
||||||
|
edgeSwipeRef.current = { startX: touch.clientX, startY: touch.clientY };
|
||||||
|
setEdgeDeltaX(0);
|
||||||
|
} else {
|
||||||
|
edgeSwipeRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollAreaRef.current && scrollAreaRef.current.scrollTop <= 0 && !isPullRefreshing) {
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||||
|
touchStartY.current = clientY;
|
||||||
|
} else {
|
||||||
|
touchStartY.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent | React.MouseEvent) => {
|
||||||
|
// 1. Check Left-Edge Swipe to Return Gesture with live translation
|
||||||
|
if (edgeSwipeRef.current && 'touches' in e && e.touches.length === 1 && currentFeedFilter) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaX = touch.clientX - edgeSwipeRef.current.startX;
|
||||||
|
const deltaY = Math.abs(touch.clientY - edgeSwipeRef.current.startY);
|
||||||
|
if (deltaX > 0 && deltaX > deltaY * 1.2) {
|
||||||
|
setEdgeDeltaX(deltaX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pull-to-refresh
|
||||||
|
if (touchStartY.current === null || isPullRefreshing) return;
|
||||||
|
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||||
|
const deltaY = clientY - touchStartY.current;
|
||||||
|
|
||||||
|
if (deltaY > 0 && scrollAreaRef.current && scrollAreaRef.current.scrollTop <= 0) {
|
||||||
|
const damped = Math.min(deltaY * 0.45, pullThreshold + 30);
|
||||||
|
setPullDistance(damped);
|
||||||
|
} else {
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
// Check if edge swipe gesture met threshold for return
|
||||||
|
if (edgeSwipeRef.current && edgeDeltaX > 80 && currentFeedFilter) {
|
||||||
|
setIsFeedDismissing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
handleReturnFromFeed();
|
||||||
|
setIsFeedDismissing(false);
|
||||||
|
setEdgeDeltaX(0);
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
setEdgeDeltaX(0);
|
||||||
|
}
|
||||||
|
edgeSwipeRef.current = null;
|
||||||
|
|
||||||
|
if (touchStartY.current === null || isPullRefreshing) return;
|
||||||
|
touchStartY.current = null;
|
||||||
|
|
||||||
|
if (pullDistance >= pullThreshold) {
|
||||||
|
setIsPullRefreshing(true);
|
||||||
|
setPullDistance(55);
|
||||||
|
setShuffledSeed(Date.now());
|
||||||
|
onRefreshData();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsPullRefreshing(false);
|
||||||
|
setPullDistance(0);
|
||||||
|
}, 700);
|
||||||
|
} else {
|
||||||
|
setPullDistance(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Infinite Scroll Trigger
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 350) {
|
||||||
|
if (hasMore && !isLoadingMore && onLoadMore) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter entries based on active tab and search
|
||||||
|
let displayedEntries = entries;
|
||||||
|
if (mobileTab === 'starred') {
|
||||||
|
displayedEntries = entries.filter((e) => e.is_starred);
|
||||||
|
} else if (mobileTab === 'listen') {
|
||||||
|
displayedEntries = entries.filter(
|
||||||
|
(e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle display order when pull-to-refresh is triggered in Home tab
|
||||||
|
if (mobileTab === 'home' && shuffledSeed > 0) {
|
||||||
|
const shuffled = [...displayedEntries];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = (shuffledSeed + i * 37) % (i + 1);
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
displayedEntries = shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into 2 columns for Masonry layout
|
||||||
|
const col1: FeedEntry[] = [];
|
||||||
|
const col2: FeedEntry[] = [];
|
||||||
|
displayedEntries.forEach((entry, idx) => {
|
||||||
|
if (idx % 2 === 0) col1.push(entry);
|
||||||
|
else col2.push(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
const playableCount = entries.filter(
|
||||||
|
(e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="waterfall-app-root">
|
||||||
|
{/* Top Category Pill Tabs & Floating Search Row (Home View) */}
|
||||||
|
{mobileTab === 'home' && (
|
||||||
|
<div className="mobile-categories-search-row">
|
||||||
|
{isSearchOpen ? (
|
||||||
|
<div className="mobile-search-bar-expanded">
|
||||||
|
<div className="mobile-search-input-wrapper">
|
||||||
|
<Search size={14} className="mobile-search-leading-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="mobile-search-input-field"
|
||||||
|
placeholder="Search all posts..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
onFilterChange({ search: e.target.value });
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
className="mobile-search-clear-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
onFilterChange({ search: '' });
|
||||||
|
}}
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="mobile-search-cancel-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
onFilterChange({ search: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Category Scrollable Pill Bar */}
|
||||||
|
<div className="mobile-tabs-pill-bar">
|
||||||
|
{currentFeedFilter ? (
|
||||||
|
<button
|
||||||
|
className="mobile-tab-pill active"
|
||||||
|
onClick={handleReturnFromFeed}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}
|
||||||
|
title={`Return to ${parentTab === 'starred' ? 'Starred' : parentTab === 'feeds' ? 'Feeds' : 'All'}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} />
|
||||||
|
<span>{currentFeedFilter.title}</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Default "All" Tab */}
|
||||||
|
<button
|
||||||
|
className={`mobile-tab-pill ${!filter.categoryId && (!filter.view || filter.view === 'inbox' || filter.view === 'all') ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ categoryId: undefined, feedId: undefined, view: 'inbox' })}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Category Folders */}
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
className={`mobile-tab-pill ${filter.categoryId === cat.id ? 'active' : ''}`}
|
||||||
|
onClick={() => onFilterChange({ categoryId: cat.id, feedId: undefined, view: 'all' })}
|
||||||
|
>
|
||||||
|
{cat.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Search Button on Far Right */}
|
||||||
|
<button
|
||||||
|
className="mobile-floating-search-btn"
|
||||||
|
onClick={() => setIsSearchOpen(true)}
|
||||||
|
title="Search posts"
|
||||||
|
>
|
||||||
|
<Search size={17} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Tab Content */}
|
||||||
|
{mobileTab === 'feeds' ? (
|
||||||
|
<FeedsTabView
|
||||||
|
categories={categories}
|
||||||
|
feeds={feeds}
|
||||||
|
onSelectFeed={(feed) => {
|
||||||
|
setParentTab('feeds');
|
||||||
|
onFilterChange({ feedId: feed.id, view: 'all' });
|
||||||
|
setMobileTab('home');
|
||||||
|
}}
|
||||||
|
onOpenAddFeed={onOpenAddFeed}
|
||||||
|
savedScrollTop={feedsScrollPos}
|
||||||
|
onScrollPosChange={setFeedsScrollPos}
|
||||||
|
/>
|
||||||
|
) : mobileTab === 'starred' ? (
|
||||||
|
<StarredTabView
|
||||||
|
entries={entries.filter((e) => e.is_starred)}
|
||||||
|
feeds={feeds}
|
||||||
|
onSelectFeedFilter={(feedId) => {
|
||||||
|
setParentTab('starred');
|
||||||
|
onFilterChange({ feedId, view: 'starred' });
|
||||||
|
setMobileTab('home');
|
||||||
|
}}
|
||||||
|
onOpenAllStarred={() => {
|
||||||
|
setParentTab('starred');
|
||||||
|
onFilterChange({ view: 'starred', feedId: undefined });
|
||||||
|
setMobileTab('home');
|
||||||
|
}}
|
||||||
|
savedScrollTop={starredScrollPos}
|
||||||
|
onScrollPosChange={setStarredScrollPos}
|
||||||
|
/>
|
||||||
|
) : mobileTab === 'settings' ? (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
<SettingsView
|
||||||
|
currentUser={currentUser}
|
||||||
|
feeds={feeds}
|
||||||
|
categories={categories}
|
||||||
|
stats={stats}
|
||||||
|
onRefreshData={onRefreshData}
|
||||||
|
onOpenAddFeed={onOpenAddFeed}
|
||||||
|
onBack={() => setMobileTab('home')}
|
||||||
|
viewPreference={viewPreference}
|
||||||
|
onChangeViewPreference={onChangeViewPreference}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Waterfall 2-Column Grid (Home / Listen / Filtered Feeds) with Pull-To-Refresh & Infinite Scroll */
|
||||||
|
<div
|
||||||
|
className="waterfall-scroll-area"
|
||||||
|
ref={scrollAreaRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onMouseDown={handleTouchStart}
|
||||||
|
onMouseMove={handleTouchMove}
|
||||||
|
onMouseUp={handleTouchEnd}
|
||||||
|
style={{
|
||||||
|
transform: isFeedDismissing
|
||||||
|
? 'translateX(100%)'
|
||||||
|
: edgeDeltaX > 0
|
||||||
|
? `translateX(${edgeDeltaX}px)`
|
||||||
|
: undefined,
|
||||||
|
transition: edgeDeltaX > 0 ? 'none' : 'transform 0.22s cubic-bezier(0.32, 0.72, 0, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pull-to-Refresh & Reshuffle Indicator */}
|
||||||
|
{(pullDistance > 0 || isPullRefreshing) && (
|
||||||
|
<div
|
||||||
|
className="pull-to-refresh-container"
|
||||||
|
style={{
|
||||||
|
height: `${pullDistance}px`,
|
||||||
|
opacity: Math.min(pullDistance / 40, 1),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPullRefreshing ? (
|
||||||
|
<div className="pull-to-refresh-content">
|
||||||
|
<Loader2 size={16} className="spinning" color="var(--accent-primary)" />
|
||||||
|
<span>Refreshing & shuffling posts...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pull-to-refresh-content">
|
||||||
|
<RefreshCw
|
||||||
|
size={15}
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${pullDistance * 3}deg)`,
|
||||||
|
transition: 'transform 0.05s linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{pullDistance >= pullThreshold
|
||||||
|
? 'Release to reload & shuffle'
|
||||||
|
: 'Pull down to refresh'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayedEntries.length === 0 ? (
|
||||||
|
<div className="reader-empty-state" style={{ height: '300px' }}>
|
||||||
|
<div style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--text-secondary)' }}>
|
||||||
|
No posts found
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>
|
||||||
|
Pull down to refresh or check other categories
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="waterfall-masonry-grid">
|
||||||
|
{/* Column 1 */}
|
||||||
|
<div className="waterfall-column">
|
||||||
|
{col1.map((entry) => renderCard(entry))}
|
||||||
|
</div>
|
||||||
|
{/* Column 2 */}
|
||||||
|
<div className="waterfall-column">
|
||||||
|
{col2.map((entry) => renderCard(entry))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infinite Scroll Loading Indicator */}
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="waterfall-infinite-loader">
|
||||||
|
<Loader2 size={16} className="spinning" color="var(--accent-primary)" />
|
||||||
|
<span>Loading more posts...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Docked Mini Audio Player (when audio is active) */}
|
||||||
|
{currentAudioUrl && (
|
||||||
|
<DockedAudioPlayer
|
||||||
|
entry={playingEntry || selectedEntry}
|
||||||
|
streamUrl={currentAudioUrl}
|
||||||
|
isPlaying={Boolean(isPlayingAudio)}
|
||||||
|
onTogglePlay={() => onTogglePlayAudio?.()}
|
||||||
|
onClose={() => onCloseAudio?.()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* iOS 5-Tab Bottom Navigation Bar (Elevated for Home Virtual Bar) */}
|
||||||
|
<XHSBottomNav
|
||||||
|
activeTab={mobileTab}
|
||||||
|
onTabChange={(tab) => {
|
||||||
|
setMobileTab(tab);
|
||||||
|
if (tab === 'starred') {
|
||||||
|
onFilterChange({ view: 'starred' });
|
||||||
|
} else if (tab === 'home') {
|
||||||
|
onFilterChange({ view: 'inbox', feedId: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
stats={stats}
|
||||||
|
playableCount={playableCount}
|
||||||
|
onDoubleTapHome={handleDoubleTapHome}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide-In Full-Screen Article Reader Sheet with Left-Edge Swipe Dismiss */}
|
||||||
|
{selectedEntry && (
|
||||||
|
<MobileArticleSheet
|
||||||
|
entry={selectedEntry}
|
||||||
|
feeds={feeds}
|
||||||
|
onClose={() => setSelectedEntryId(null)}
|
||||||
|
onToggleStar={onToggleStar}
|
||||||
|
onToggleRead={onToggleRead}
|
||||||
|
onUpdateEntry={onUpdateEntry}
|
||||||
|
onPlayAudio={onPlayAudio}
|
||||||
|
currentAudioUrl={currentAudioUrl}
|
||||||
|
isPlayingAudio={isPlayingAudio}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderCard(entry: FeedEntry) {
|
||||||
|
const coverImage = extractFirstImage(entry.content || entry.raw_content);
|
||||||
|
const hasAi = Boolean(entry.variants?.['ai_summary']);
|
||||||
|
const hasTts = Boolean(entry.variants?.['tts'] || entry.variants?.['podcast_tts']);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className={`xhs-card ${entry.is_read ? 'read' : ''}`}
|
||||||
|
onClick={() => handleCardClick(entry)}
|
||||||
|
>
|
||||||
|
{/* Cover / SmartArt Preview */}
|
||||||
|
{coverImage ? (
|
||||||
|
<div className="xhs-card-image-wrap">
|
||||||
|
<img src={coverImage} alt="" className="xhs-card-image" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SmartArtCard
|
||||||
|
title={entry.title}
|
||||||
|
seed={entry.feed_title || entry.id}
|
||||||
|
excerpt={entry.content || entry.raw_content}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card Body & Metadata */}
|
||||||
|
<div className="xhs-card-body">
|
||||||
|
<div className="xhs-card-title">{entry.title}</div>
|
||||||
|
|
||||||
|
<div className="xhs-card-footer">
|
||||||
|
<div className="xhs-card-author-group">
|
||||||
|
<span className="xhs-card-feed-title">{entry.feed_title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
{hasAi && <Sparkles size={12} color="var(--badge-ai)" />}
|
||||||
|
{hasTts && <Headphones size={12} color="var(--badge-tts)" />}
|
||||||
|
<span>{formatRelativeDate(entry.published_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Home, Rss, Headphones, Star, Settings } from 'lucide-react';
|
||||||
|
import { Stats } from '../../types';
|
||||||
|
|
||||||
|
export type MobileTab = 'home' | 'feeds' | 'listen' | 'starred' | 'settings';
|
||||||
|
|
||||||
|
interface XHSBottomNavProps {
|
||||||
|
activeTab: MobileTab;
|
||||||
|
onTabChange: (tab: MobileTab) => void;
|
||||||
|
stats: Stats | null;
|
||||||
|
playableCount?: number;
|
||||||
|
onDoubleTapHome?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XHSBottomNav: React.FC<XHSBottomNavProps> = ({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
stats,
|
||||||
|
playableCount = 0,
|
||||||
|
onDoubleTapHome,
|
||||||
|
}) => {
|
||||||
|
const lastHomeTapRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const handleHomeClick = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (activeTab === 'home' && now - lastHomeTapRef.current < 350) {
|
||||||
|
onDoubleTapHome?.();
|
||||||
|
}
|
||||||
|
lastHomeTapRef.current = now;
|
||||||
|
onTabChange('home');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="ios-bottom-nav">
|
||||||
|
<button
|
||||||
|
className={`bottom-nav-item ${activeTab === 'home' ? 'active' : ''}`}
|
||||||
|
onClick={handleHomeClick}
|
||||||
|
>
|
||||||
|
<Home className="bottom-nav-icon" />
|
||||||
|
<span>Home</span>
|
||||||
|
{stats && stats.total_unread > 0 && (
|
||||||
|
<span className="bottom-nav-badge">
|
||||||
|
{stats.total_unread > 99 ? '99+' : stats.total_unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bottom-nav-item ${activeTab === 'feeds' ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange('feeds')}
|
||||||
|
>
|
||||||
|
<Rss className="bottom-nav-icon" />
|
||||||
|
<span>Feeds</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bottom-nav-item ${activeTab === 'listen' ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange('listen')}
|
||||||
|
>
|
||||||
|
<Headphones className="bottom-nav-icon" />
|
||||||
|
<span>Listen</span>
|
||||||
|
{playableCount > 0 && (
|
||||||
|
<span className="bottom-nav-badge" style={{ background: 'var(--badge-tts)' }}>
|
||||||
|
{playableCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bottom-nav-item ${activeTab === 'starred' ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange('starred')}
|
||||||
|
>
|
||||||
|
<Star className="bottom-nav-icon" />
|
||||||
|
<span>Starred</span>
|
||||||
|
{stats && stats.total_starred > 0 && (
|
||||||
|
<span className="bottom-nav-badge" style={{ background: 'var(--star-gold)' }}>
|
||||||
|
{stats.total_starred}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bottom-nav-item ${activeTab === 'settings' ? 'active' : ''}`}
|
||||||
|
onClick={() => onTabChange('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="bottom-nav-icon" />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
+8
-1312
File diff suppressed because it is too large
Load Diff
+121
-2
@@ -71,6 +71,17 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getUserPreferences(): Promise<Record<string, any>> {
|
||||||
|
return request<Record<string, any>>('/user/preferences');
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateUserPreferences(preferences: Record<string, any>): Promise<Record<string, any>> {
|
||||||
|
return request<Record<string, any>>('/user/preferences', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(preferences),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
async getStats(): Promise<Stats> {
|
async getStats(): Promise<Stats> {
|
||||||
return request<Stats>('/stats');
|
return request<Stats>('/stats');
|
||||||
@@ -97,10 +108,10 @@ export const api = {
|
|||||||
return request<Feed[]>('/feeds');
|
return request<Feed[]>('/feeds');
|
||||||
},
|
},
|
||||||
|
|
||||||
async createFeed(feed_url: string, category_id?: string, title?: string): Promise<Feed> {
|
async createFeed(feed_url: string, category_id?: string, title?: string, icon_url?: string): Promise<Feed> {
|
||||||
return request<Feed>('/feeds', {
|
return request<Feed>('/feeds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ feed_url, category_id, title }),
|
body: JSON.stringify({ feed_url, category_id, title, icon_url }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -109,6 +120,7 @@ export const api = {
|
|||||||
data: {
|
data: {
|
||||||
title?: string;
|
title?: string;
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
|
icon_url?: string;
|
||||||
refresh_interval_minutes?: number;
|
refresh_interval_minutes?: number;
|
||||||
timeout_seconds?: number;
|
timeout_seconds?: number;
|
||||||
}
|
}
|
||||||
@@ -186,6 +198,18 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async toggleShare(id: string): Promise<{ shared: boolean; share_uuid: string | null; entry: FeedEntry }> {
|
||||||
|
return request<{ shared: boolean; share_uuid: string | null; entry: FeedEntry }>(`/entries/${id}/share`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateAISummary(id: string): Promise<FeedEntry> {
|
||||||
|
return request<FeedEntry>(`/entries/${id}/ai-summary`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Rules
|
// Rules
|
||||||
async listRules(): Promise<any[]> {
|
async listRules(): Promise<any[]> {
|
||||||
return request<any[]>('/rules');
|
return request<any[]>('/rules');
|
||||||
@@ -262,7 +286,102 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data || {}),
|
body: JSON.stringify(data || {}),
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search History
|
||||||
|
async getSearchHistory(query?: string, limit: number = 10): Promise<import('../types').SearchHistoryItem[]> {
|
||||||
|
const qParam = query ? `?q=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`;
|
||||||
|
return request<import('../types').SearchHistoryItem[]>(`/search/history${qParam}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async recordSearch(keyword: string): Promise<void> {
|
||||||
|
return request<void>('/search/history', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ keyword }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteSearchHistory(keyword?: string): Promise<void> {
|
||||||
|
const qParam = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
|
||||||
|
return request<void>(`/search/history${qParam}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Patch Entry (Atomic transition mutation)
|
||||||
|
async patchEntry(
|
||||||
|
id: string,
|
||||||
|
patch: {
|
||||||
|
is_read?: boolean;
|
||||||
|
is_starred?: boolean;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
variants?: Record<string, any>;
|
||||||
|
}
|
||||||
|
): Promise<{ entry: FeedEntry; diff: any }> {
|
||||||
|
return request<{ entry: FeedEntry; diff: any }>(`/entries/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Background Task Queue & Jobs
|
||||||
|
async listJobs(params?: { type?: string; status?: string; limit?: number; offset?: number }): Promise<{ jobs: any[]; limit: number; offset: number }> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.type) query.set('type', params.type);
|
||||||
|
if (params?.status) query.set('status', params.status);
|
||||||
|
if (params?.limit) query.set('limit', params.limit.toString());
|
||||||
|
if (params?.offset) query.set('offset', params.offset.toString());
|
||||||
|
return request(`/jobs?${query.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getJob(id: string): Promise<any> {
|
||||||
|
return request<any>(`/jobs/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelJob(id: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>(`/jobs/${id}/cancel`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async retryJob(id: string): Promise<{ message: string }> {
|
||||||
|
return request<{ message: string }>(`/jobs/${id}/retry`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Real-Time Server-Sent Events Stream
|
||||||
|
connectEventStream(onEvent: (eventType: string, data: any) => void): () => void {
|
||||||
|
const eventSource = new EventSource(`${API_BASE}/events/stream`);
|
||||||
|
|
||||||
|
const eventNames = [
|
||||||
|
'connected',
|
||||||
|
'ping',
|
||||||
|
'preferences_updated',
|
||||||
|
'job_progress',
|
||||||
|
'job_completed',
|
||||||
|
'job_failed',
|
||||||
|
'post_created',
|
||||||
|
'post_updated',
|
||||||
|
];
|
||||||
|
|
||||||
|
eventNames.forEach((name) => {
|
||||||
|
eventSource.addEventListener(name, (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
onEvent(name, data);
|
||||||
|
} catch {
|
||||||
|
onEvent(name, e.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
console.warn('SSE connection error:', err);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/* RSSPipe - Base App Styles & Global Utilities */
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-app);
|
||||||
|
color: var(--text-primary);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Minimal Micro-Scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base App Shell */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-app);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared Top Navigation Header */
|
||||||
|
.app-header {
|
||||||
|
height: var(--header-height);
|
||||||
|
min-height: var(--header-height);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
z-index: 40;
|
||||||
|
user-select: none;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left, .header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wide Prominent Search Bar */
|
||||||
|
.search-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 480px;
|
||||||
|
flex: 0 1 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 2rem 0 2.2rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon-inside {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-shortcut-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.6rem;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimal Action Buttons */
|
||||||
|
.btn-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discrete View Mode Toggle */
|
||||||
|
.discrete-view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discrete-view-toggle:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinning Animation for Refresh */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
display: inline-block !important;
|
||||||
|
animation: spin 0.8s linear infinite !important;
|
||||||
|
transform-origin: center center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Autocomplete Dropdown */
|
||||||
|
.search-autocomplete-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 50;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete-item:hover, .search-autocomplete-item.active {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete-delete-btn {
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete-item:hover .search-autocomplete-delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-autocomplete-delete-btn:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: var(--danger-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge Utilities */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-unread {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-starred {
|
||||||
|
background: var(--star-bg);
|
||||||
|
color: var(--star-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-ai {
|
||||||
|
background: var(--badge-ai-soft);
|
||||||
|
color: var(--badge-ai);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-tts {
|
||||||
|
background: var(--badge-tts-soft);
|
||||||
|
color: var(--badge-tts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/* RSSPipe - Shared Modal Dialog Styles */
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 150;
|
||||||
|
animation: modalFadeIn 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalScaleUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalScaleUp {
|
||||||
|
from { transform: scale(0.96); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.15rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shortcut Modal Table */
|
||||||
|
.shortcut-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-badge {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/* RSSPipe - High-Precision Metallic Settings Styles */
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
width: 100vw;
|
||||||
|
background: var(--bg-app);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-btn:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-btn.active {
|
||||||
|
background: var(--bg-card-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fluid Full-Width Settings Content Pane */
|
||||||
|
.settings-content-pane {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2.25rem 3.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input, .settings-select, .settings-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-input:focus, .settings-select:focus, .settings-textarea:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feed Management List */
|
||||||
|
.settings-feed-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-feed-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prompt Template Card */
|
||||||
|
.prompt-template-card {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-template-card:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Metric Grid */
|
||||||
|
.stats-grid-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-metric-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.15rem 1.25rem;
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-metric-value {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-metric-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adaptation for settings */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-container {
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: var(--bottom-nav-height);
|
||||||
|
}
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.settings-nav-btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.settings-content-pane {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
/* RSSPipe - Desktop 3-Split Workspace Styles */
|
||||||
|
|
||||||
|
.three-pane-container {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - var(--header-height));
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Splitter Gutter Handle */
|
||||||
|
.pane-splitter {
|
||||||
|
width: 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: col-resize;
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-splitter::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 2px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-splitter:hover::after,
|
||||||
|
.pane-splitter.is-dragging::after {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
width: 2px;
|
||||||
|
left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-splitter:hover,
|
||||||
|
.pane-splitter.is-dragging {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global drag overlay to prevent pointer events on iframes/text selection */
|
||||||
|
.splitter-drag-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pane 1: Sidebar */
|
||||||
|
.desktop-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar.collapsed {
|
||||||
|
width: 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border-right: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-title {
|
||||||
|
padding: 0.75rem 1rem 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
min-height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.83rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item.active {
|
||||||
|
background: var(--bg-card-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-item-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scroll-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-category-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-category-header:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chevron-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chevron-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-category-children {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pane 2: Article List Column */
|
||||||
|
.desktop-list-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header-bar {
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-scope-pill-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-pill-btn {
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope-pill-btn.active {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-mode-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-mode-btn.active {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-articles-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact Article Row (2-Line Minimal) */
|
||||||
|
.article-row-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 0.6rem;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-row-compact:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-row-compact.selected {
|
||||||
|
background: var(--bg-card-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-row-compact.unread .compact-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-indicator-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-indicator-dot.read {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-content-col {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-date {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-action-col {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-icon-item,
|
||||||
|
.compact-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-icon-btn:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Non-Compact (Expanded) Article Card Mode */
|
||||||
|
.article-card-rich {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-rich:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-rich.selected {
|
||||||
|
background: var(--bg-card-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-rich .card-title {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.38;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-rich.unread .card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card-rich .card-snippet {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-icons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chips-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chip {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-keyword-chip {
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
color: #2563eb;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tag-chip {
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
color: #059669;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pane 3: Detail Reader Pane */
|
||||||
|
.desktop-reader-pane {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
gap: 0.75rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-toolbar-left, .reader-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split Action Button & Dropdown in Reader Toolbar */
|
||||||
|
.split-action-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-action-main-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
height: 30px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-top-left-radius: var(--radius-sm);
|
||||||
|
border-bottom-left-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-action-main-btn:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-action-arrow-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 30px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-top-right-radius: var(--radius-sm);
|
||||||
|
border-bottom-right-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-action-arrow-btn:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
width: 210px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-dropdown-item:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reader Scroll Area (Far Right Scrollbar) */
|
||||||
|
.reader-scroll-area {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-content-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 860px;
|
||||||
|
padding: 2.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-feed-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-article-title {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.28;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-meta-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-meta-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-author-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-meta-dot {
|
||||||
|
color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keywords and Tags Chips */
|
||||||
|
.reader-chips-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chips-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chips-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-chip {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-tag-chip {
|
||||||
|
background: rgba(24, 24, 27, 0.04);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-header-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
margin: 1.5rem 0 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI Summary Box in Desktop Reader */
|
||||||
|
.desktop-ai-summary-box {
|
||||||
|
background: var(--badge-ai-soft);
|
||||||
|
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-ai-summary-box.ai-error-box {
|
||||||
|
background: var(--danger-soft, rgba(239, 68, 68, 0.08));
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-summary-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--badge-ai);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-summary-body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio Player Bar in Desktop Reader */
|
||||||
|
.desktop-inline-audio-bar {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-play-circle-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-play-circle-btn:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reader Typography Body */
|
||||||
|
.reader-body-rendered {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered p {
|
||||||
|
margin-bottom: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 1.5rem 0 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered blockquote {
|
||||||
|
border-left: 3px solid var(--border-strong);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered code {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-body-rendered pre {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reader Empty State */
|
||||||
|
.reader-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reader-empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* RSSPipe - High-Precision Metallic Monochrome Design Tokens */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Surfaces & Backgrounds (Crisp Titanium & Neutral Monochrome) */
|
||||||
|
--bg-app: #f4f4f5;
|
||||||
|
--bg-surface: #ffffff;
|
||||||
|
--bg-sidebar: #fafafa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-card-hover: #f4f4f5;
|
||||||
|
--bg-card-active: #e4e4e7;
|
||||||
|
--bg-subtle: #f4f4f5;
|
||||||
|
--bg-overlay: rgba(9, 9, 11, 0.45);
|
||||||
|
--bg-docked: rgba(255, 255, 255, 0.96);
|
||||||
|
|
||||||
|
/* Borders (Neutral Steel / Zinc) */
|
||||||
|
--border-light: #f4f4f5;
|
||||||
|
--border-subtle: #e4e4e7;
|
||||||
|
--border-strong: #d4d4d8;
|
||||||
|
--border-focus: #18181b;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Text Colors (Pure High-Contrast Zinc) */
|
||||||
|
--text-primary: #09090b;
|
||||||
|
--text-secondary: #27272a;
|
||||||
|
--text-muted: #71717a;
|
||||||
|
--text-dim: #a1a1aa;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
|
/* Brand Accents (Titanium Black & Steel) */
|
||||||
|
--accent-primary: #18181b;
|
||||||
|
--accent-hover: #27272a;
|
||||||
|
--accent-soft: rgba(24, 24, 27, 0.06);
|
||||||
|
--accent-border: #d4d4d8;
|
||||||
|
|
||||||
|
/* Semantic Highlight Colors */
|
||||||
|
--star-gold: #d97706;
|
||||||
|
--star-bg: #fef3c7;
|
||||||
|
--success: #059669;
|
||||||
|
--success-soft: #d1fae5;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--danger-soft: #fee2e2;
|
||||||
|
--badge-ai: #4f46e5;
|
||||||
|
--badge-ai-soft: #e0e7ff;
|
||||||
|
--badge-tts: #0284c7;
|
||||||
|
--badge-tts-soft: #e0f2fe;
|
||||||
|
|
||||||
|
/* Radiuses */
|
||||||
|
--radius-xs: 4px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadows (Subtle, Crisp Elevation) */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||||
|
--shadow-docked: 0 -4px 20px rgba(0, 0, 0, 0.07);
|
||||||
|
|
||||||
|
/* Layout Dimensions */
|
||||||
|
--header-height: 50px;
|
||||||
|
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--bottom-nav-height: calc(50px + env(safe-area-inset-bottom, 0px));
|
||||||
|
--docked-audio-height: 54px;
|
||||||
|
--sidebar-min-width: 200px;
|
||||||
|
--sidebar-default-width: 250px;
|
||||||
|
--list-min-width: 300px;
|
||||||
|
--list-default-width: 400px;
|
||||||
|
--reader-min-width: 480px;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-smooth: 350ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,712 @@
|
|||||||
|
/* RSSPipe - Mobile Waterfall & iOS Native Experience Styles */
|
||||||
|
|
||||||
|
.waterfall-app-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-app);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Header */
|
||||||
|
.mobile-top-header {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1rem;
|
||||||
|
z-index: 30;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Category Pill Tabs & Floating Search Row */
|
||||||
|
.mobile-categories-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
position: relative;
|
||||||
|
z-index: 25;
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs-pill-bar {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tabs-pill-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab-pill {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-tab-pill.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Search Button on Far Right */
|
||||||
|
.mobile-floating-search-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 0.45rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-floating-search-btn:active {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable Slide-In Search Bar (iOS Style) */
|
||||||
|
.mobile-search-bar-expanded {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
z-index: 30;
|
||||||
|
animation: searchFadeSlide 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes searchFadeSlide {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-leading-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-input-field {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: 0 1.85rem 0 2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-input-field:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-cancel-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-search-cancel-btn:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Infinite Scroll Loading & End Indicator */
|
||||||
|
.waterfall-infinite-loader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 1.25rem 0 1.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Waterfall Scroll Area */
|
||||||
|
.waterfall-scroll-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 0.65rem;
|
||||||
|
padding-bottom: calc(var(--bottom-nav-height) + var(--docked-audio-height) + 1rem);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pull to Refresh Indicator */
|
||||||
|
.pull-to-refresh-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2-Column Waterfall Masonry Grid */
|
||||||
|
.waterfall-masonry-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waterfall-column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Xiaohongshu-Style Post Card */
|
||||||
|
.xhs-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card.read {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card-image-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 280px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SmartArt Card (For posts without images) */
|
||||||
|
.smart-art-cover {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 1.15rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-art-quote {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-art-accent-dot {
|
||||||
|
width: 24px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Content Details */
|
||||||
|
.xhs-card-body {
|
||||||
|
padding: 0.65rem 0.75rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card-author-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xhs-card-feed-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* iOS Bottom Navigation Bar */
|
||||||
|
.ios-bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: var(--bottom-nav-height);
|
||||||
|
background: var(--bg-docked);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 4px 0.5rem calc(var(--safe-area-bottom) + 2px);
|
||||||
|
z-index: 50;
|
||||||
|
user-select: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-item.active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: calc(50% - 16px);
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Slide-In Full-Screen Article Sheet */
|
||||||
|
.mobile-article-sheet {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-article-sheet.slide-in {
|
||||||
|
animation: sheetSlideIn 0.3s cubic-bezier(0.32, 0.72, 0, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheetSlideIn {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-top-nav {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: 1.15rem 1rem calc(56px + var(--safe-area-bottom));
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Sheet Bottom Action Toolbar */
|
||||||
|
.mobile-sheet-bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: calc(48px + var(--safe-area-bottom));
|
||||||
|
padding: 4px 0.85rem calc(var(--safe-area-bottom) + 2px);
|
||||||
|
background: var(--bg-docked);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
z-index: 110;
|
||||||
|
user-select: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-bottom-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-bottom-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-action-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast), transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-action-btn:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-action-btn.starred {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-action-btn.shared {
|
||||||
|
color: #2563eb;
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-action-btn.audio-active {
|
||||||
|
color: #059669;
|
||||||
|
background: rgba(5, 150, 105, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropup menu for bottom popup split button */
|
||||||
|
.dropup-menu {
|
||||||
|
top: auto !important;
|
||||||
|
bottom: calc(100% + 8px) !important;
|
||||||
|
right: 0 !important;
|
||||||
|
transform-origin: bottom right;
|
||||||
|
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px var(--border-subtle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enforce Strict Horizontal Overflow Containment & Word Wrapping */
|
||||||
|
.mobile-sheet-scroll * {
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered a,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered p,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered h1,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered h2,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered h3,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered h4,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered span {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered pre,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered code {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered img,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered video,
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered iframe {
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet-scroll .reader-body-rendered table {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Docked Mini Audio Player */
|
||||||
|
.docked-audio-player {
|
||||||
|
position: fixed;
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: calc(var(--bottom-nav-height) + 0.5rem);
|
||||||
|
height: var(--docked-audio-height);
|
||||||
|
background: var(--bg-docked);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-docked);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
z-index: 45;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docked-audio-player:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docked-progress-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2.5px;
|
||||||
|
background: var(--badge-tts);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docked-audio-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docked-audio-title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docked-audio-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded Full Player Modal */
|
||||||
|
.audio-expanded-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-expanded-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
|
||||||
|
padding: 1.5rem 1.5rem 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-scrubber-slider {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-speed-chips {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-speed-chip {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-speed-chip.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
+20
-2
@@ -1,6 +1,14 @@
|
|||||||
|
export interface UserPreferences {
|
||||||
|
auto_read?: boolean;
|
||||||
|
view_preference?: ViewPreference;
|
||||||
|
theme?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
preferences?: UserPreferences;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +26,7 @@ export interface Feed {
|
|||||||
title: string;
|
title: string;
|
||||||
feed_url: string;
|
feed_url: string;
|
||||||
site_url?: string;
|
site_url?: string;
|
||||||
|
icon_url?: string;
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
category_title?: string;
|
category_title?: string;
|
||||||
is_virtual_digest: boolean;
|
is_virtual_digest: boolean;
|
||||||
@@ -58,7 +67,7 @@ export interface FeedEntry {
|
|||||||
published_at: string;
|
published_at: string;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
is_starred: boolean;
|
is_starred: boolean;
|
||||||
share_uuid?: string;
|
share_uuid?: string | null;
|
||||||
variants?: Record<string, EntryVariant>;
|
variants?: Record<string, EntryVariant>;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -91,12 +100,14 @@ export interface Stats {
|
|||||||
total_feeds: number;
|
total_feeds: number;
|
||||||
total_unread: number;
|
total_unread: number;
|
||||||
total_starred: number;
|
total_starred: number;
|
||||||
|
total_shared?: number;
|
||||||
total_categories: number;
|
total_categories: number;
|
||||||
total_rules?: number;
|
total_rules?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewType = 'inbox' | 'starred' | 'all';
|
export type ViewType = 'inbox' | 'starred' | 'all' | 'shared';
|
||||||
export type ViewMode = 'split' | 'waterfall' | 'settings';
|
export type ViewMode = 'split' | 'waterfall' | 'settings';
|
||||||
|
export type ViewPreference = 'auto' | 'mobile' | 'desktop';
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
view: ViewType;
|
view: ViewType;
|
||||||
@@ -131,3 +142,10 @@ export interface AITestResult {
|
|||||||
model: string;
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchHistoryItem {
|
||||||
|
keyword: string;
|
||||||
|
last_searched_at: string;
|
||||||
|
search_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+60
-3
@@ -51,6 +51,42 @@ export function formatRelativeDate(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format exact date & time as YYYY-MM-DD HH:mm
|
||||||
|
*/
|
||||||
|
export function formatDateTime(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = pad(d.getMonth() + 1);
|
||||||
|
const day = pad(d.getDate());
|
||||||
|
const hours = pad(d.getHours());
|
||||||
|
const mins = pad(d.getMinutes());
|
||||||
|
return `${year}-${month}-${day} ${hours}:${mins}`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format exact date as YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export function formatDateOnly(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = pad(d.getMonth() + 1);
|
||||||
|
const day = pad(d.getDate());
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimate reading time in minutes
|
* Estimate reading time in minutes
|
||||||
*/
|
*/
|
||||||
@@ -76,6 +112,28 @@ export function getEntryTags(variants?: Record<string, { text_value?: string }>)
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate elegant SmartArt background palettes based on a text seed
|
||||||
|
*/
|
||||||
|
const SMART_ART_PALETTES = [
|
||||||
|
{ bg: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)', accent: '#475569', text: '#0f172a' },
|
||||||
|
{ bg: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)', accent: '#2563eb', text: '#1e3a8a' },
|
||||||
|
{ bg: 'linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%)', accent: '#7c3aed', text: '#4c1d95' },
|
||||||
|
{ bg: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)', accent: '#059669', text: '#064e3b' },
|
||||||
|
{ bg: 'linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%)', accent: '#ea580c', text: '#7c2d12' },
|
||||||
|
{ bg: 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)', accent: '#db2777', text: '#831843' },
|
||||||
|
{ bg: 'linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%)', accent: '#0d9488', text: '#134e4a' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSmartArtTheme(seed: string): { bg: string; accent: string; text: string } {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % SMART_ART_PALETTES.length;
|
||||||
|
return SMART_ART_PALETTES[index];
|
||||||
|
}
|
||||||
|
|
||||||
export function renderMarkdownText(markdown?: string): string {
|
export function renderMarkdownText(markdown?: string): string {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
|
|
||||||
@@ -103,7 +161,7 @@ export function renderMarkdownText(markdown?: string): string {
|
|||||||
let content = bulletMatch[1];
|
let content = bulletMatch[1];
|
||||||
// Format bold and code
|
// Format bold and code
|
||||||
content = content
|
content = content
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-main); font-weight: 600;">$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-primary); font-weight: 600;">$1</strong>')
|
||||||
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
||||||
output.push(`<li style="line-height: 1.55; color: var(--text-secondary);">${content}</li>`);
|
output.push(`<li style="line-height: 1.55; color: var(--text-secondary);">${content}</li>`);
|
||||||
} else {
|
} else {
|
||||||
@@ -112,7 +170,7 @@ export function renderMarkdownText(markdown?: string): string {
|
|||||||
inList = false;
|
inList = false;
|
||||||
}
|
}
|
||||||
let content = trimmed
|
let content = trimmed
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-main); font-weight: 600;">$1</strong>')
|
.replace(/\*\*(.+?)\*\*/g, '<strong style="color: var(--text-primary); font-weight: 600;">$1</strong>')
|
||||||
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
.replace(/`(.+?)`/g, '<code style="background: var(--bg-card); padding: 2px 4px; border-radius: 4px; font-size: 0.8em;">$1</code>');
|
||||||
output.push(`<p style="margin: 0.5rem 0; line-height: 1.6; color: var(--text-secondary);">${content}</p>`);
|
output.push(`<p style="margin: 0.5rem 0; line-height: 1.6; color: var(--text-secondary);">${content}</p>`);
|
||||||
}
|
}
|
||||||
@@ -124,4 +182,3 @@ export function renderMarkdownText(markdown?: string): string {
|
|||||||
|
|
||||||
return output.join('');
|
return output.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user