diff --git a/docs/003_backlog.md b/docs/003_backlog.md index d55a7bb..b9240f5 100644 --- a/docs/003_backlog.md +++ b/docs/003_backlog.md @@ -6,11 +6,13 @@ This document records planned features, UX enhancements, and architectural exten ## 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. -- **Scope**: - - Auto-suggest matches across article titles, feed names, author tags, and category labels. - - Keyboard navigation within search dropdown (`ArrowDown`, `ArrowUp`, `Enter` to jump directly to article or feed). - - Search history / recent queries quick access. +- **Status**: **Partially Implemented (Search History Table & Dropdown Active)** +- **Implemented**: + - SQLite table `search_history (keyword TEXT PRIMARY KEY, last_searched_at DATETIME, search_count INTEGER)`. + - Backend endpoints `GET /api/search/history`, `POST /api/search/history`, `DELETE /api/search/history`. + - 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**: - - **Auto-Scraper**: Automatically fetch and cache `favicon.ico`, ``, or `` from the feed's website root during initial subscription. - - **Manual Customization**: Allow uploading or specifying a custom icon URL in `Settings > Subscriptions > Edit Feed`. + - **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. + - **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. --- @@ -141,4 +145,3 @@ This document records planned features, UX enhancements, and architectural exten - Outbound webhook action in rule engine supporting Telegram, Discord, Slack, and generic JSON HTTP POST. - Template engine with interpolation of article variables (`title`, `feed_title`, `url`, `published_at`, `ai_summary`, `tags`). - Retry mechanism with exponential backoff on delivery failure. - diff --git a/docs/007_ui_ux_architecture_and_design_specification.md b/docs/007_ui_ux_architecture_and_design_specification.md new file mode 100644 index 0000000..50b3e5d --- /dev/null +++ b/docs/007_ui_ux_architecture_and_design_specification.md @@ -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) | +-------------------------+ |

Article Title

| +| - Hacker News (5)| | [Unread Dot] Feed Title |

Source: Hacker News • 2h ago

| +| - Ars Technica(3)| | Post Headline Title | | +| ⚙️ Settings | | 2h ago • 3 min read | [AI Summary Box (Collapsible)] | +| | +-------------------------+ | | +| | | Next Article Row... | [Markdown / Full-Text Content Body] | +| | | | ++--------------------+-----------------------------+--------------------------------------+ +``` + +### 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. diff --git a/docs/008_desktop_view_feedback_and_specifications.md b/docs/008_desktop_view_feedback_and_specifications.md new file mode 100644 index 0000000..b9dd618 --- /dev/null +++ b/docs/008_desktop_view_feedback_and_specifications.md @@ -0,0 +1,128 @@ +# 008 - Desktop 3-Pane View Feedback & Design Specifications + +This document comprehensively records all user feedback, UX directives, and architectural decisions made for the **Desktop 3-Pane View** in RSSPipe. + +--- + +## 1. Design Aesthetics & Visual Identity + +- **Theme Palette**: Metal-inspired, refined monochrome with sleek slate/zinc surfaces (`#09090b`, `#18181b`, `#27272a`), crisp typography (Inter/system), and subtle 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 (`rsspipe_auto_read_scroll`): + +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. + +--- + +## 6. Real-Time State Synchronization + +- **Sidebar Counts**: Toggling `is_read`, `is_starred`, or `share_uuid` immediately triggers a stats reload to keep sidebar counters (`inbox`, `starred`, `shared`) synchronized in real time. +- **Persistence**: Switching feeds or categories in the sidebar updates the middle article list without abruptly reloading or clearing the active reading canvas. diff --git a/internal/api/handlers.go b/internal/api/handlers.go index db0eb06..23dca97 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -12,6 +12,7 @@ import ( "sync" "time" + aiActions "git.pengzhan.dev/haopengzhan/rsspipe/internal/actions/ai" "git.pengzhan.dev/haopengzhan/rsspipe/internal/ai" "git.pengzhan.dev/haopengzhan/rsspipe/internal/auth" "git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler" @@ -222,11 +223,12 @@ func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) { if req.Title != nil && strings.TrimSpace(*req.Title) != "" { title = strings.TrimSpace(*req.Title) } - if title == "" { - title = req.FeedURL + iconURL := "" + if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" { + iconURL = strings.TrimSpace(*req.IconURL) } - feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, req.CategoryID) + feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, iconURL, req.CategoryID) if err != nil { h.writeError(w, http.StatusInternalServerError, err.Error()) return @@ -426,7 +428,7 @@ func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) { title = item.FeedURL } - _, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, catID) + _, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID) if err == nil { createdCount++ } @@ -636,6 +638,70 @@ func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, updatedEntry) } +// ToggleShareEntry toggles public sharing for an entry and returns the updated state +func (h *Handler) ToggleShareEntry(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + shareUUID, err := h.db.ToggleShareEntry(id) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + entry, _ := h.db.GetEntry(id) + h.writeJSON(w, http.StatusOK, map[string]interface{}{ + "shared": shareUUID != nil, + "share_uuid": shareUUID, + "entry": entry, + }) +} + +// GenerateAISummary on-demand generates an AI summary for a single article +func (h *Handler) GenerateAISummary(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + entry, err := h.db.GetEntry(id) + if err != nil { + h.writeError(w, http.StatusNotFound, "entry not found") + return + } + + cfg := aiActions.LoadConfig(h.db) + if !cfg.Enabled { + h.writeError(w, http.StatusBadRequest, "AI is not enabled in settings") + return + } + if cfg.APIKey == "" && cfg.Provider != "ollama" { + h.writeError(w, http.StatusBadRequest, "AI API key is not configured in settings") + return + } + + prompt := "Summarize the key points of this article clearly with 3 concise bullet points and a brief takeaway." + resultText, err := h.aiClient.GenerateEnrichment(r.Context(), cfg, prompt, *entry) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "AI generation failed: "+err.Error()) + return + } + + variant := models.EntryVariant{ + EntryID: entry.ID, + VariantKey: "ai_summary", + ContentType: "text", + TextValue: resultText, + Metadata: map[string]interface{}{ + "model": cfg.Model, + }, + } + if err := h.db.SetVariant(variant); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to save AI summary: "+err.Error()) + return + } + + updated, err := h.db.GetEntry(id) + if err != nil { + h.writeError(w, http.StatusInternalServerError, err.Error()) + return + } + h.writeJSON(w, http.StatusOK, updated) +} + // Rules API Handlers func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) { rules, err := h.db.ListRules() @@ -967,3 +1033,62 @@ func (h *Handler) TestAIConnection(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, http.StatusOK, res) } + +// Search History Handlers +func (h *Handler) GetSearchHistory(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + limitStr := r.URL.Query().Get("limit") + limit := 10 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + history, err := h.db.GetSearchHistory(q, limit) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to get search history: "+err.Error()) + return + } + if history == nil { + history = []models.SearchHistoryItem{} + } + h.writeJSON(w, http.StatusOK, history) +} + +func (h *Handler) RecordSearch(w http.ResponseWriter, r *http.Request) { + var req struct { + Keyword string `json:"keyword"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if strings.TrimSpace(req.Keyword) == "" { + h.writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"}) + return + } + + if err := h.db.RecordSearch(req.Keyword); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to record search: "+err.Error()) + return + } + h.writeJSON(w, http.StatusOK, map[string]string{"status": "recorded"}) +} + +func (h *Handler) DeleteSearchHistory(w http.ResponseWriter, r *http.Request) { + keyword := r.URL.Query().Get("keyword") + if keyword != "" { + if err := h.db.DeleteSearchHistory(keyword); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to delete search history: "+err.Error()) + return + } + } else { + if err := h.db.ClearSearchHistory(); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to clear search history: "+err.Error()) + return + } + } + h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + diff --git a/internal/api/server.go b/internal/api/server.go index b01b613..2ede34c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -65,6 +65,8 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http r.Get("/entries/{id}", h.GetEntry) r.Put("/entries/{id}/read", h.UpdateEntryRead) r.Put("/entries/{id}/star", h.UpdateEntryStar) + r.Post("/entries/{id}/share", h.ToggleShareEntry) + r.Post("/entries/{id}/ai-summary", h.GenerateAISummary) r.Post("/entries/mark-all-read", h.MarkAllRead) r.Post("/entries/{id}/scrape", h.ScrapeEntry) r.Get("/entries/{id}/variants", h.ListEntryVariants) @@ -82,6 +84,11 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http r.Put("/settings/ai", h.UpdateAISettings) 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 r.Post("/opml/import", h.ImportOPML) r.Get("/opml/export", h.ExportOPML) diff --git a/internal/db/db.go b/internal/db/db.go index 245b674..ccfa59f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -145,12 +146,27 @@ func (d *DB) migrate() error { value TEXT 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); ` if _, err := d.Exec(schema); err != nil { return err } // Migration for timeout_seconds column if older DB exists _, _ = 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 rules table schema if older table exists var testCol string @@ -294,11 +310,30 @@ func (d *DB) DeleteCategory(id string) error { 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 func (d *DB) ListFeeds() ([]models.Feed, error) { query := ` 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, 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, @@ -321,10 +356,10 @@ func (d *DB) ListFeeds() ([]models.Feed, error) { var feeds []models.Feed for rows.Next() { 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 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, &etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount, ); err != nil { @@ -337,6 +372,12 @@ func (d *DB) ListFeeds() ([]models.Feed, error) { if siteURL.Valid { f.SiteURL = siteURL.String } + if iconURL.Valid { + f.IconURL = iconURL.String + } + if f.IconURL == "" { + f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL) + } if etag.Valid { f.ETag = etag.String } @@ -358,7 +399,7 @@ func (d *DB) ListFeeds() ([]models.Feed, error) { func (d *DB) GetFeed(id string) (*models.Feed, error) { query := ` 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, 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, @@ -371,10 +412,10 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) { GROUP BY f.id ` 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 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, &etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount, ) @@ -388,6 +429,12 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) { if siteURL.Valid { f.SiteURL = siteURL.String } + if iconURL.Valid { + f.IconURL = iconURL.String + } + if f.IconURL == "" { + f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL) + } if etag.Valid { f.ETag = etag.String } @@ -406,13 +453,13 @@ func (d *DB) GetFeed(id string) (*models.Feed, error) { func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) { 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 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, ).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, &lastFetched, &fetchErr, &f.CreatedAt, ) @@ -426,16 +473,26 @@ func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) { if siteURL.Valid { f.SiteURL = siteURL.String } + if iconURL.Valid { + f.IconURL = iconURL.String + } + if f.IconURL == "" { + f.IconURL = ResolveFavicon(f.SiteURL, f.FeedURL) + } 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() + if iconURL == "" { + iconURL = ResolveFavicon(siteURL, feedURL) + } f := &models.Feed{ ID: uuid.New().String(), Title: title, FeedURL: feedURL, SiteURL: siteURL, + IconURL: iconURL, CategoryID: categoryID, RefreshIntervalMinutes: 30, TimeoutSeconds: 10, @@ -446,8 +503,8 @@ func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*mo catID = *categoryID } _, err := d.Exec( - "INSERT INTO feeds (id, title, feed_url, site_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, + "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, f.IconURL, catID, f.RefreshIntervalMinutes, f.TimeoutSeconds, f.CreatedAt, ) if err != nil { return nil, err @@ -466,6 +523,11 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error { title = *req.Title } + iconURL := feed.IconURL + if req.IconURL != nil { + iconURL = strings.TrimSpace(*req.IconURL) + } + catID := feed.CategoryID if req.CategoryID != nil { if *req.CategoryID == "" { @@ -492,9 +554,9 @@ func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error { _, err = d.Exec(` UPDATE feeds - SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ? + SET title = ?, icon_url = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ? WHERE id = ? - `, title, cID, interval, timeout, id) + `, title, iconURL, cID, interval, timeout, id) return err } @@ -636,6 +698,8 @@ func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) { whereClauses = append(whereClauses, "e.is_read = 0") case "starred": 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 != "" { @@ -760,6 +824,25 @@ func (d *DB) UpdateEntryStarred(id string, isStarred bool) error { 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 { if feedID != nil && *feedID != "" { _, err := d.Exec("UPDATE feed_entries SET is_read = 1 WHERE feed_id = ?", *feedID) @@ -1066,6 +1149,7 @@ func (d *DB) GetStats() (*models.Stats, error) { if err != nil { 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) if err != nil { return nil, err @@ -1116,3 +1200,70 @@ func (d *DB) GetSettingsMap() (map[string]string, error) { } 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 +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 5a80541..3381aec 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -43,7 +43,7 @@ func TestDatabaseOperations(t *testing.T) { } // 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 { t.Fatalf("CreateFeed failed: %v", err) } diff --git a/internal/models/models.go b/internal/models/models.go index c30eb2b..3db6ea1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -13,6 +13,13 @@ type User struct { 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 type Category struct { ID string `json:"id"` @@ -29,6 +36,7 @@ type Feed struct { Title string `json:"title"` FeedURL string `json:"feed_url"` SiteURL string `json:"site_url"` + IconURL string `json:"icon_url,omitempty"` CategoryID *string `json:"category_id,omitempty"` CategoryTitle string `json:"category_title,omitempty"` IsVirtualDigest bool `json:"is_virtual_digest"` @@ -108,6 +116,7 @@ type Stats struct { TotalFeeds int `json:"total_feeds"` TotalUnread int `json:"total_unread"` TotalStarred int `json:"total_starred"` + TotalShared int `json:"total_shared"` TotalCategories int `json:"total_categories"` TotalRules int `json:"total_rules"` } @@ -129,6 +138,7 @@ type CreateFeedRequest struct { FeedURL string `json:"feed_url"` CategoryID *string `json:"category_id,omitempty"` Title *string `json:"title,omitempty"` + IconURL *string `json:"icon_url,omitempty"` RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"` TimeoutSeconds *int `json:"timeout_seconds,omitempty"` } @@ -137,6 +147,7 @@ type CreateFeedRequest struct { type UpdateFeedRequest struct { Title *string `json:"title,omitempty"` CategoryID *string `json:"category_id,omitempty"` + IconURL *string `json:"icon_url,omitempty"` RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"` TimeoutSeconds *int `json:"timeout_seconds,omitempty"` } diff --git a/web/src/App.tsx b/web/src/App.tsx index bfc4c52..97a104a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,20 +1,20 @@ import React, { useState, useEffect, useCallback } from 'react'; import { api } from './services/api'; -import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode } from './types'; -import { LoginView } from './components/LoginView'; -import { Header } from './components/Header'; -import { ThreePaneView } from './components/ThreePaneView'; -import { WaterfallView } from './components/WaterfallView'; -import { SettingsView } from './components/SettingsView'; -import { AddFeedModal } from './components/AddFeedModal'; -import { OPMLModal } from './components/OPMLModal'; -import { KeyboardShortcutsModal } from './components/KeyboardShortcutsModal'; +import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewMode, ViewPreference } from './types'; +import { LoginView } from './components/common/LoginView'; +import { Header } from './components/common/Header'; +import { ThreePaneView } from './components/desktop/ThreePaneView'; +import { WaterfallView } from './components/mobile/WaterfallView'; +import { SettingsView } from './components/settings/SettingsView'; +import { AddFeedModal } from './components/common/AddFeedModal'; +import { OPMLModal } from './components/common/OPMLModal'; +import { KeyboardShortcutsModal } from './components/common/KeyboardShortcutsModal'; export const App: React.FC = () => { const [currentUser, setCurrentUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); - // App State with localStorage Persistence + // App Filter State with Persistence const [filter, setFilter] = useState(() => { const saved = localStorage.getItem('rsspipe_filter'); if (saved) { @@ -27,49 +27,82 @@ export const App: React.FC = () => { return { view: 'inbox', search: '' }; }); - const [viewMode, setViewMode] = useState(() => { - const saved = localStorage.getItem('rsspipe_view_mode') as ViewMode | null; - if (saved && (saved === 'split' || saved === 'waterfall' || saved === 'settings')) { - return saved; - } - return window.innerWidth < 768 ? 'waterfall' : 'split'; + // View Preference: 'auto' | 'mobile' | 'desktop' + const [viewPreference, setViewPreference] = useState(() => { + const saved = localStorage.getItem('rsspipe_view_preference') as ViewPreference | null; + return saved && (saved === 'auto' || saved === 'mobile' || saved === 'desktop') ? saved : 'auto'; }); - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { - return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true'; + // Active View Mode: 'split' | 'waterfall' | 'settings' + const [viewMode, setViewMode] = useState(() => { + 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(() => { return localStorage.getItem('rsspipe_selected_entry_id'); }); - const [activeArticle, setActiveArticle] = useState(null); - const [categories, setCategories] = useState([]); const [feeds, setFeeds] = useState([]); const [entries, setEntries] = useState([]); const [stats, setStats] = useState(null); - const [focusedIndex, setFocusedIndex] = useState(0); const [isRefreshing, setIsRefreshing] = useState(false); const [showAddFeedModal, setShowAddFeedModal] = useState(false); const [showOPMLModal, setShowOPMLModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); - // Persist state updates to localStorage + // Global Audio Engine State + const [currentAudioUrl, setCurrentAudioUrl] = useState(null); + const [isPlayingAudio, setIsPlayingAudio] = useState(false); + const [playingEntry, setPlayingEntry] = useState(null); + + // Persist filter useEffect(() => { localStorage.setItem('rsspipe_filter', JSON.stringify(filter)); }, [filter]); + // Persist view preference & synchronize active view layout 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]); useEffect(() => { - localStorage.setItem('rsspipe_sidebar_collapsed', sidebarCollapsed ? 'true' : 'false'); - }, [sidebarCollapsed]); + if (selectedEntryId) { + localStorage.setItem('rsspipe_selected_entry_id', selectedEntryId); + } + }, [selectedEntryId]); - // Check auth session + // Check auth session on startup useEffect(() => { const checkAuth = async () => { const token = localStorage.getItem('rsspipe_jwt'); @@ -95,7 +128,7 @@ export const App: React.FC = () => { return () => window.removeEventListener('auth:unauthorized', handleUnauthorized); }, []); - // Load sidebar data (Feeds, Categories, Stats) + // Load sidebar data (Categories, Feeds, Stats) const loadSidebarData = useCallback(async () => { if (!currentUser) return; try { @@ -112,24 +145,34 @@ export const App: React.FC = () => { } }, [currentUser]); - // Load entries matching current filter + // Load entries matching filter const loadEntries = useCallback(async () => { if (!currentUser) return; try { const res = await api.listEntries({ view: filter.view, - category_id: filter.categoryId, feed_id: filter.feedId, - search: filter.search, - limit: 100, + category_id: filter.categoryId, + search: filter.search || undefined, + limit: 200, }); - setEntries(res.entries); + setEntries(res.entries || []); + + 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) { console.error('Failed to load entries:', err); } }, [currentUser, filter]); - // Initial load useEffect(() => { if (currentUser) { loadSidebarData(); @@ -137,166 +180,109 @@ export const App: React.FC = () => { } }, [currentUser, loadSidebarData, loadEntries]); - // Sync focused index & active article when entries change without disrupting reading state - useEffect(() => { - if (entries.length > 0) { - if (activeArticle) { - const found = entries.find((e) => e.id === activeArticle.id); - if (found) { - setActiveArticle(found); - setFocusedIndex(entries.findIndex((e) => e.id === activeArticle.id)); - return; - } - // Active article remains in reader canvas from prior feed, but list is unselected - setFocusedIndex(-1); - return; - } - - 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); + // Global Refresh Action with guaranteed animation feedback duration + const handleRefreshAll = async () => { + if (isRefreshing) return; + setIsRefreshing(true); + const minDelay = new Promise((resolve) => setTimeout(resolve, 600)); + try { + await Promise.all([ + api.refreshAllFeeds(), + minDelay, + ]); + await Promise.all([loadSidebarData(), loadEntries()]); + } catch (err) { + console.error('Refresh all failed:', err); + } finally { + setIsRefreshing(false); } }; - // Refresh data only on initial mount or when user clicks refresh / changes filter - - // Toggle Read Action with optimistic update - const handleToggleRead = async (entry: FeedEntry, e?: React.MouseEvent) => { - if (e) e.stopPropagation(); - const newStatus = !entry.is_read; - - // Optimistic update + // Toggle Read Action + const handleToggleRead = async (entry: FeedEntry) => { + const nextRead = !entry.is_read; setEntries((prev) => - prev.map((item) => - item.id === entry.id ? { ...item, is_read: newStatus } : item - ) + prev.map((e) => (e.id === entry.id ? { ...e, is_read: nextRead } : e)) ); - if (activeArticle && activeArticle.id === entry.id) { - setActiveArticle((prev) => (prev ? { ...prev, is_read: newStatus } : prev)); - } - try { - await api.updateRead(entry.id, newStatus); + await api.updateRead(entry.id, nextRead); loadSidebarData(); } catch (err) { - console.error('Failed to update read status:', 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)); - } + console.error('Update read failed:', err); } }; - // Toggle Star Action with optimistic update + // Toggle Star Action const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => { if (e) e.stopPropagation(); - const newStatus = !entry.is_starred; - - // Optimistic update + const nextStar = !entry.is_starred; setEntries((prev) => - prev.map((item) => - item.id === entry.id ? { ...item, is_starred: newStatus } : item - ) + prev.map((e) => (e.id === entry.id ? { ...e, is_starred: nextStar } : e)) ); - if (activeArticle && activeArticle.id === entry.id) { - setActiveArticle((prev) => (prev ? { ...prev, is_starred: newStatus } : prev)); - } - try { - await api.updateStar(entry.id, newStatus); + await api.updateStar(entry.id, nextStar); loadSidebarData(); } catch (err) { - console.error('Failed to update star status:', 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)); - } + console.error('Update star failed:', err); } }; - // Mark All Read + // Mark All As Read Action const handleMarkAllRead = async () => { try { await api.markAllRead(filter.feedId, filter.categoryId); - setEntries((prev) => prev.map((item) => ({ ...item, is_read: true }))); - if (activeArticle) { - setActiveArticle((prev) => (prev ? { ...prev, is_read: true } : prev)); - } + setEntries((prev) => prev.map((e) => ({ ...e, is_read: true }))); loadSidebarData(); } catch (err) { - console.error('Failed to mark all read:', err); + console.error('Mark all read failed:', err); } }; - // Refresh All Feeds - const handleRefreshAll = async () => { - setIsRefreshing(true); - try { - await api.refreshAllFeeds(); - setTimeout(() => { - loadSidebarData(); - loadEntries(); - setIsRefreshing(false); - }, 2500); - } catch (err) { - console.error('Failed to refresh feeds:', err); - setIsRefreshing(false); + // Update Single Entry Action + const handleUpdateEntry = (updated: FeedEntry) => { + setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : 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 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Don't intercept inside input fields - if ( - document.activeElement?.tagName === 'INPUT' || - document.activeElement?.tagName === 'TEXTAREA' || - document.activeElement?.tagName === 'SELECT' - ) { - if (e.key === 'Escape') { - (document.activeElement as HTMLElement).blur(); - } + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { return; } @@ -304,157 +290,99 @@ export const App: React.FC = () => { setShowAddFeedModal(false); setShowOPMLModal(false); setShowShortcutsModal(false); + if (viewMode === 'settings') { + setViewMode(lastContentMode); + } return; } - switch (e.key) { - case '[': // Toggle Sidebar - e.preventDefault(); - setSidebarCollapsed((prev) => !prev); - break; + if (e.key === '?' || (e.shiftKey && e.key === '/')) { + e.preventDefault(); + setShowShortcutsModal(true); + return; + } - case 'j': - case 'J': // Next item - e.preventDefault(); - 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; + if (e.key === '1') { + setViewMode('split'); + return; + } + if (e.key === '2') { + setViewMode('waterfall'); + return; + } + if (e.key === '3') { + setViewMode('settings'); + return; + } - case 'k': - case 'K': // Previous item - e.preventDefault(); - if (entries.length > 0) { - setFocusedIndex((prev) => { - 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(); - setShowAddFeedModal(true); - } - break; - - case 'r': - case 'R': // Refresh feeds + if (e.key === 'r' || e.key === 'R') { + if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); handleRefreshAll(); - break; + return; + } + } - case '1': // Switch to Desktop 3-Split View + if (e.key === 'a' || e.key === 'A') { + if (e.shiftKey) { e.preventDefault(); - setViewMode('split'); - break; - - case '2': // Switch to Xiaohongshu Waterfall View + handleMarkAllRead(); + } else if (!e.metaKey && !e.ctrlKey) { e.preventDefault(); - setViewMode('waterfall'); - break; + setShowAddFeedModal(true); + } + return; + } - case '3': // Switch to Settings View + // 3-Split specific shortcuts + if (viewMode === 'split' && entries.length > 0) { + const currentIndex = entries.findIndex((item) => item.id === selectedEntryId); + + if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); - setViewMode('settings'); - break; - - case '?': // Shortcuts modal - e.preventDefault(); - setShowShortcutsModal(true); - break; - - default: - if (e.shiftKey && e.key === 'A') { - e.preventDefault(); - if (window.confirm('Mark all items in this view as read?')) { - handleMarkAllRead(); + const nextIndex = Math.min(currentIndex + 1, entries.length - 1); + if (entries[nextIndex]) { + const nextEntry = entries[nextIndex]; + setSelectedEntryId(nextEntry.id); + if (!nextEntry.is_read) { + handleToggleRead(nextEntry); } } - break; + } else if (e.key === 'k' || e.key === 'ArrowUp') { + e.preventDefault(); + const prevIndex = Math.max(currentIndex - 1, 0); + if (entries[prevIndex]) { + const prevEntry = entries[prevIndex]; + setSelectedEntryId(prevEntry.id); + if (!prevEntry.is_read) { + handleToggleRead(prevEntry); + } + } + } else if (e.key === 's') { + e.preventDefault(); + const currentEntry = entries[currentIndex]; + if (currentEntry) handleToggleStar(currentEntry); + } else if (e.key === 'm') { + e.preventDefault(); + const currentEntry = entries[currentIndex]; + if (currentEntry) handleToggleRead(currentEntry); + } else if (e.key === 'o') { + const currentEntry = entries[currentIndex]; + if (currentEntry?.url) { + window.open(currentEntry.url, '_blank'); + } + } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [entries, focusedIndex, activeArticle]); + }, [viewMode, entries, selectedEntryId, isRefreshing, lastContentMode]); if (authLoading) { return ( -
-
+
+
Loading RSSPipe...
@@ -462,62 +390,80 @@ export const App: React.FC = () => { } if (!currentUser) { - return setCurrentUser(user)} />; + return setCurrentUser(u)} />; } return (
- {/* App Header */} -
setFilter((prev) => ({ ...prev, search: val }))} - viewMode={viewMode} - onViewModeChange={setViewMode} - isRefreshing={isRefreshing} - onRefreshAll={handleRefreshAll} - onOpenAddFeed={() => setShowAddFeedModal(true)} - onOpenShortcuts={() => setShowShortcutsModal(true)} - onLogout={async () => { - await api.logout(); - setCurrentUser(null); - }} - sidebarCollapsed={sidebarCollapsed} - onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)} - /> + {/* Top Header is rendered in 3-Split desktop view */} + {viewMode === 'split' && ( +
setFilter((prev) => ({ ...prev, search }))} + isRefreshing={isRefreshing} + onRefreshAll={handleRefreshAll} + onOpenAddFeed={() => setShowAddFeedModal(true)} + onOpenSettings={() => setViewMode('settings')} + onOpenShortcuts={() => setShowShortcutsModal(true)} + onLogout={async () => { + await api.logout(); + setCurrentUser(null); + }} + /> + )} - {/* Main View Mode Content */} + {/* Main View Router */} {viewMode === 'split' ? ( setFilter((prev) => ({ ...prev, ...newF }))} + onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))} categories={categories} feeds={feeds} - stats={stats} entries={entries} - selectedIndex={focusedIndex} - activeArticle={activeArticle} - onSelectEntry={handleSelectEntry} - onToggleRead={handleToggleRead} - onToggleStar={handleToggleStar} - onMarkAllRead={handleMarkAllRead} - onUpdateEntry={(updated) => { - setActiveArticle(updated); - setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e))); + selectedEntryId={selectedEntryId} + onSelectEntry={(entry) => { + setSelectedEntryId(entry.id); + if (!entry.is_read) { + handleToggleRead(entry); + } }} - sidebarCollapsed={sidebarCollapsed} + onToggleStar={handleToggleStar} + onToggleRead={handleToggleRead} + onMarkAllRead={handleMarkAllRead} + onUpdateEntry={handleUpdateEntry} + stats={stats} + onOpenSettings={() => setViewMode('settings')} + onOpenAddFeed={() => setShowAddFeedModal(true)} + onPlayAudio={handlePlayAudio} + currentAudioUrl={currentAudioUrl} + isPlayingAudio={isPlayingAudio} /> ) : viewMode === 'waterfall' ? ( setFilter((prev) => ({ ...prev, ...newF }))} + onFilterChange={(newFilter) => setFilter((prev) => ({ ...prev, ...newFilter }))} categories={categories} + feeds={feeds} entries={entries} + stats={stats} + currentUser={currentUser} onToggleRead={handleToggleRead} onToggleStar={handleToggleStar} - onUpdateEntry={(updated) => { - setActiveArticle(updated); - setEntries((prev) => prev.map((e) => (e.id === updated.id ? updated : e))); + onUpdateEntry={handleUpdateEntry} + onRefreshData={() => { + loadSidebarData(); + loadEntries(); }} + onOpenAddFeed={() => setShowAddFeedModal(true)} + onPlayAudio={handlePlayAudio} + currentAudioUrl={currentAudioUrl} + isPlayingAudio={isPlayingAudio} + onTogglePlayAudio={handleTogglePlayAudio} + onCloseAudio={handleCloseAudio} + playingEntry={playingEntry} + viewPreference={viewPreference} + onChangeViewPreference={handleChangeViewPreference} + onSwitchToDesktop={() => setViewMode('split')} /> ) : ( { loadEntries(); }} onOpenAddFeed={() => setShowAddFeedModal(true)} + onBack={() => setViewMode(lastContentMode)} + viewPreference={viewPreference} + onChangeViewPreference={handleChangeViewPreference} /> )} - {/* Modals */} + {/* Shared Modal Dialogs */} {showAddFeedModal && ( void; - onFeedAdded: (feed: Feed) => void; -} - -export const AddFeedModal: React.FC = ({ - categories, - onClose, - onFeedAdded, -}) => { - const [feedUrl, setFeedUrl] = useState(''); - const [title, setTitle] = useState(''); - const [categoryId, setCategoryId] = useState(''); - const [newCatTitle, setNewCatTitle] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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 ( -
-
e.stopPropagation()}> -
-

Add New Subscription

- -
- -
- {error &&
{error}
} - -
- - setFeedUrl(e.target.value)} - placeholder="https://news.ycombinator.com/rss" - required - autoFocus - /> -
- -
- - setTitle(e.target.value)} - placeholder="Leave blank to auto-detect from feed" - /> -
- -
- - -
- -
- - setNewCatTitle(e.target.value)} - placeholder="e.g. AI & Tech" - /> -
- -
- - -
-
-
-
- ); -}; diff --git a/web/src/components/ArticleModal.tsx b/web/src/components/ArticleModal.tsx deleted file mode 100644 index c773a53..0000000 --- a/web/src/components/ArticleModal.tsx +++ /dev/null @@ -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 = ({ - entry, - onClose, - onToggleStar, - onToggleRead, -}) => { - const shareUrl = `${window.location.origin}/share/${entry.share_uuid || entry.id}.html`; - - const handleScroll = (e: React.UIEvent) => { - 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 ( -
-
e.stopPropagation()} - > - {/* Top Header Actions */} -
-
- - - - - - {entry.feed_title} - -
- - -
- - {/* Article Scroll Body */} -
-
-
- - {entry.feed_title} - - • - - {' '} - {new Date(entry.published_at).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - })} - - • - {estimateReadingTime(entry.content || entry.raw_content)} - {entry.author && ( - <> - • - - {entry.author} - - - )} -
- -

- - {entry.title} - -

- -
-
-
-
-
- ); -}; diff --git a/web/src/components/CompactListView.tsx b/web/src/components/CompactListView.tsx deleted file mode 100644 index 4ae887a..0000000 --- a/web/src/components/CompactListView.tsx +++ /dev/null @@ -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 = ({ - 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 ( -
-
🎉
-

Inbox Zero!

-

No articles to display in this view.

-
- ); - } - - const selectedEntry = entries[selectedIndex] || entries[0]; - - return ( -
- {/* Left List Pane (Gmail style) */} -
- {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 ( -
(rowRefs.current[index] = el)} - className={`compact-row ${isSelected ? 'focused' : ''} ${ - entry.is_read ? 'is-read' : '' - }`} - onClick={() => onSelectEntry(index)} - > - {/* Star toggle */} - - - {/* Mark read toggle */} - - - {/* Feed Title */} -
- {entry.feed_title} -
- - {/* Article Title & Snippet */} -
- {entry.title} - - {snippet} -
- - {/* Date */} -
{pubDate}
-
- ); - })} -
- - {/* Right Reading Pane */} - {selectedEntry && ( -
-
-
- {selectedEntry.feed_title} - • - - {' '} - {new Date(selectedEntry.published_at).toLocaleString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} - - {selectedEntry.author && ( - <> - • - - {selectedEntry.author} - - - )} -
- -

- - {selectedEntry.title} ↗ - -

- - {/* Actions Toolbar */} -
- - - - - - - Open Original (V) - - - - - Public Share - -
-
- -
-
- )} -
- ); -}; diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx deleted file mode 100644 index ebfeb46..0000000 --- a/web/src/components/Header.tsx +++ /dev/null @@ -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 = ({ - search, - onSearchChange, - viewMode, - onViewModeChange, - isRefreshing, - onRefreshAll, - onOpenAddFeed, - onOpenShortcuts, - onLogout, - sidebarCollapsed, - onToggleSidebar, -}) => { - const searchInputRef = useRef(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 ( -
- {/* Left: Logo & Sidebar Toggle */} -
- {!isMobile && viewMode === 'split' && ( - - )} - -
onViewModeChange(isMobile ? 'waterfall' : 'split')} - > -
- -
- RSSPipe - {!isMobile && Gateway} -
-
- - {/* Center: Search Bar */} -
- - onSearchChange(e.target.value)} - placeholder={isMobile ? 'Search...' : "Search feeds, 'is:unread'..."} - /> - {!search && !isMobile && /} -
- - {/* Right: Actions */} - {isMobile ? ( - // Mobile Header Right: 3 Dots Menu Button -
- - - {mobileMenuOpen && ( - <> -
setMobileMenuOpen(false)} - /> -
- - - - -
- - - - - - - -
- - -
- - )} -
- ) : ( - // Desktop Header Right Actions -
- - - - -
- - - -
- - - - -
- )} -
- ); -}; diff --git a/web/src/components/KeyboardShortcutsModal.tsx b/web/src/components/KeyboardShortcutsModal.tsx deleted file mode 100644 index 2312876..0000000 --- a/web/src/components/KeyboardShortcutsModal.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { X, Keyboard } from 'lucide-react'; - -interface KeyboardShortcutsModalProps { - onClose: () => void; -} - -export const KeyboardShortcutsModal: React.FC = ({ - 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 ( -
-
e.stopPropagation()} style={{ maxWidth: 580 }}> -
-
- -

Google Reader & Gmail Keyboard Shortcuts

-
- -
- -
- - - - - - - - - {shortcuts.map((sc, i) => ( - - - - - ))} - -
- SHORTCUT - - ACTION -
- - {sc.key} - - - {sc.desc} -
- -
- Press ESC to close any modal. -
-
-
-
- ); -}; diff --git a/web/src/components/LoginView.tsx b/web/src/components/LoginView.tsx deleted file mode 100644 index df92daf..0000000 --- a/web/src/components/LoginView.tsx +++ /dev/null @@ -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 = ({ onLoginSuccess }) => { - const [username, setUsername] = useState('admin'); - const [password, setPassword] = useState('admin123456'); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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 ( -
-
-
-
- -
- RSSPipe -
-

RSS-Driven Information Gateway

- - {error &&
{error}
} - -
-
- - setUsername(e.target.value)} - placeholder="admin" - required - /> -
- -
- - setPassword(e.target.value)} - placeholder="••••••••" - required - /> -
- - - -
- Default single-user credentials: admin / admin123456 -
-
-
-
- ); -}; diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx deleted file mode 100644 index 25e2ddd..0000000 --- a/web/src/components/SettingsView.tsx +++ /dev/null @@ -1,3144 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Category, Feed, Rule, RuleAction, RuleCondition, Stats, User, AISettings, PromptTemplate, AITestResult } from '../types'; -import { api } from '../services/api'; -import { - Rss, - Folder, - Trash2, - RefreshCw, - Plus, - Search, - CheckCircle2, - UploadCloud, - Download, - Loader2, - Edit2, - AlertCircle, - X, - Lock, - Key, - Sparkles, - Zap, - Eye, - FileSpreadsheet, - Shield, - Clock, - Timer, - ChevronDown, - Bot, -} from 'lucide-react'; - -interface SettingsViewProps { - currentUser: User; - feeds: Feed[]; - categories: Category[]; - stats: Stats | null; - onRefreshData: () => void; - onOpenAddFeed: () => void; -} - -interface BuilderCondition { - id: string; - field: string; - operator: string; - value: string; - selectedFeedIds: string[]; - selectedCatIds: string[]; -} - -interface BuilderActionsState { - scrapeFullText: boolean; - markStarred: boolean; - markRead: boolean; - setVariant: boolean; - variantKey: string; - variantValue: string; - contentType: string; - // AI Actions - aiEnrich: boolean; - aiPrompt: string; - aiVariantKey: string; - aiAgentAction: boolean; - aiAgentPrompt: string; - aiAgentAllowedActions: string[]; -} - -export const SettingsView: React.FC = ({ - currentUser, - feeds, - categories, - stats, - onRefreshData, - onOpenAddFeed, -}) => { - const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'rules' | 'ai' | 'viewer' | 'opml' | 'account'>('feeds'); - const [autoReadBottom, setAutoReadBottom] = useState(() => { - return localStorage.getItem('rsspipe_auto_read_bottom') !== 'false'; - }); - const [feedSearch, setFeedSearch] = useState(''); - const [newCatTitle, setNewCatTitle] = useState(''); - const [loading, setLoading] = useState(false); - const [message, setMessage] = useState(null); - - // AI Settings State - const [aiSettings, setAiSettings] = useState({ - enabled: false, - provider: 'openai', - base_url: 'https://api.openai.com/v1', - api_key: '', - model: 'gpt-4o-mini', - temperature: 0.3, - max_tokens: 1000, - prompts: [], - }); - const [aiLoading, setAiLoading] = useState(false); - const [aiTesting, setAiTesting] = useState(false); - const [aiTestResult, setAiTestResult] = useState(null); - const [aiSaving, setAiSaving] = useState(false); - const [showApiKey, setShowApiKey] = useState(false); - const [editingPrompt, setEditingPrompt] = useState(null); - - // Rules State - const [rules, setRules] = useState([]); - const [rulesLoading, setRulesLoading] = useState(false); - const [showRuleModal, setShowRuleModal] = useState(false); - const [editingRuleId, setEditingRuleId] = useState(null); - const [saveLoading, setSaveLoading] = useState(false); - const [saveDropdownOpen, setSaveDropdownOpen] = useState(false); - - // Form State for Automation Rule Builder - const [ruleName, setRuleName] = useState(''); - const [ruleTrigger, setRuleTrigger] = useState<'on_post_created' | 'on_variant_updated'>('on_post_created'); - const [variantSubEvent, setVariantSubEvent] = useState<'key_added' | 'value_changed' | 'key_deleted'>('key_added'); - const [targetVariantKey, setTargetVariantKey] = useState('keywords'); - const [ruleMatchMode, setRuleMatchMode] = useState<'all' | 'any'>('all'); - - const [builderConditions, setBuilderConditions] = useState([ - { - id: 'cond-1', - field: 'feed_id', - operator: 'in', - value: '', - selectedFeedIds: [], - selectedCatIds: [], - }, - ]); - - const [feedPickerSearch, setFeedPickerSearch] = useState(''); - - const [builderActions, setBuilderActions] = useState({ - scrapeFullText: true, - markStarred: false, - markRead: false, - setVariant: false, - variantKey: 'tags', - variantValue: '', - contentType: 'text', - aiEnrich: false, - aiPrompt: '', - aiVariantKey: 'ai_summary', - aiAgentAction: false, - aiAgentPrompt: '', - aiAgentAllowedActions: ['mark_read', 'mark_starred'], - }); - - // Edit Feed Modal State - const [editingFeed, setEditingFeed] = useState(null); - const [editTitle, setEditTitle] = useState(''); - const [editCategoryId, setEditCategoryId] = useState(''); - const [editInterval, setEditInterval] = useState(30); - const [editTimeout, setEditTimeout] = useState(10); - const [editSaving, setEditSaving] = useState(false); - - // Debug Log Modal State - const [debugFeed, setDebugFeed] = useState(null); - - // Custom Delete Confirmation Modal State - const [deleteConfirm, setDeleteConfirm] = useState<{ - type: 'feed' | 'category' | 'rule'; - id: string; - title: string; - } | null>(null); - - // OPML state - const [opmlFile, setOpmlFile] = useState(null); - const [opmlLoading, setOpmlLoading] = useState(false); - const [opmlResult, setOpmlResult] = useState(null); - - // Change Password state - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const [passwordLoading, setPasswordLoading] = useState(false); - const [passwordError, setPasswordError] = useState(null); - const [passwordSuccess, setPasswordSuccess] = useState(null); - - const handleChangePassword = async (e: React.FormEvent) => { - e.preventDefault(); - setPasswordError(null); - setPasswordSuccess(null); - - if (newPassword.length < 6) { - setPasswordError('New password must be at least 6 characters long'); - return; - } - - if (newPassword !== confirmPassword) { - setPasswordError('New passwords do not match'); - return; - } - - setPasswordLoading(true); - try { - await api.changePassword(oldPassword, newPassword); - setPasswordSuccess('Password updated successfully! Please use your new password next time you log in.'); - setOldPassword(''); - setNewPassword(''); - setConfirmPassword(''); - } catch (err: any) { - setPasswordError(err.message || 'Failed to update password'); - } finally { - setPasswordLoading(false); - } - }; - - useEffect(() => { - loadRules(); - loadAISettings(); - }, []); - - const loadRules = async () => { - setRulesLoading(true); - try { - const data = await api.listRules(); - setRules(data || []); - } catch { - // ignore - } finally { - setRulesLoading(false); - } - }; - - const loadAISettings = async () => { - setAiLoading(true); - try { - const data = await api.getAISettings(); - if (data) { - setAiSettings(data); - } - } catch { - // ignore - } finally { - setAiLoading(false); - } - }; - - const handleSaveAISettings = async (e: React.FormEvent) => { - e.preventDefault(); - setAiSaving(true); - try { - const payload = { - ...aiSettings, - // Auto-enable if API key or Ollama is configured unless explicitly set - enabled: aiSettings.enabled || Boolean(aiSettings.api_key.trim()) || aiSettings.provider === 'ollama', - }; - const updated = await api.updateAISettings(payload); - setAiSettings(updated); - setMessage('AI Configuration saved successfully.'); - setTimeout(() => setMessage(null), 3000); - } catch (err: any) { - alert(err.message || 'Failed to save AI settings'); - } finally { - setAiSaving(false); - } - }; - - const handleTestAIConnection = async () => { - setAiTesting(true); - setAiTestResult(null); - try { - const res = await api.testAIConnection(aiSettings); - setAiTestResult(res); - if (res.success) { - setAiSettings((prev) => ({ ...prev, enabled: true })); - } - } catch (err: any) { - setAiTestResult({ - success: false, - message: err.message || 'Connection test failed', - latency_ms: 0, - model: aiSettings.model, - }); - } finally { - setAiTesting(false); - } - }; - - const openCreateRuleModal = () => { - setEditingRuleId(null); - setRuleName(''); - setRuleTrigger('on_post_created'); - setVariantSubEvent('key_added'); - setTargetVariantKey('keywords'); - setRuleMatchMode('all'); - setBuilderConditions([ - { - id: 'cond-1', - field: 'feed_id', - operator: 'in', - value: '', - selectedFeedIds: [], - selectedCatIds: [], - }, - ]); - setFeedPickerSearch(''); - setBuilderActions({ - scrapeFullText: true, - markStarred: false, - markRead: false, - setVariant: false, - variantKey: 'tags', - variantValue: '', - contentType: 'text', - aiEnrich: false, - aiPrompt: aiSettings.prompts?.[0]?.prompt || '', - aiVariantKey: 'ai_summary', - aiAgentAction: false, - aiAgentPrompt: '', - aiAgentAllowedActions: ['mark_read', 'mark_starred'], - }); - setSaveDropdownOpen(false); - setShowRuleModal(true); - }; - - const openEditRuleModal = (rule: Rule) => { - setEditingRuleId(rule.id); - setRuleName(rule.name); - setRuleTrigger(rule.trigger === 'on_variant_updated' ? 'on_variant_updated' : 'on_post_created'); - setRuleMatchMode(rule.match_mode === 'any' ? 'any' : 'all'); - - const mappedConds: BuilderCondition[] = (rule.conditions || []).map((c, idx) => { - let feedIds: string[] = []; - let catIds: string[] = []; - let strVal = ''; - - if (c.field === 'feed_id') { - if (Array.isArray(c.value)) feedIds = c.value; - else if (typeof c.value === 'string') feedIds = c.value.split(',').map((s) => s.trim()).filter(Boolean); - } else if (c.field === 'category_id') { - if (Array.isArray(c.value)) catIds = c.value; - else if (typeof c.value === 'string') catIds = c.value.split(',').map((s) => s.trim()).filter(Boolean); - } else { - strVal = typeof c.value === 'object' ? JSON.stringify(c.value) : String(c.value ?? ''); - } - - return { - id: `cond-${idx}-${Date.now()}`, - field: c.field, - operator: c.operator, - value: strVal, - selectedFeedIds: feedIds, - selectedCatIds: catIds, - }; - }); - - if (mappedConds.length === 0) { - mappedConds.push({ - id: 'cond-1', - field: 'feed_id', - operator: 'in', - value: '', - selectedFeedIds: [], - selectedCatIds: [], - }); - } - setBuilderConditions(mappedConds); - - let scrape = false; - let starred = false; - let read = false; - let setVar = false; - let vKey = 'tags'; - let vVal = ''; - let cType = 'text'; - let aiEnrichVal = false; - let aiPromptVal = ''; - let aiVariantKeyVal = 'ai_summary'; - let aiAgentVal = false; - let aiAgentPromptVal = ''; - let aiAgentAllowedVal: string[] = ['mark_read', 'mark_starred']; - - rule.actions?.forEach((a) => { - if (a.type === 'readability_scrape') scrape = true; - if (a.type === 'mark_starred') starred = true; - if (a.type === 'mark_read') read = true; - if (a.type === 'set_variant') { - setVar = true; - vKey = String(a.params?.variant_key || a.params?.key || 'tags'); - vVal = String(a.params?.value || ''); - cType = String(a.params?.content_type || 'text'); - } - if (a.type === 'ai_enrich' || a.type === 'ai_summary') { - aiEnrichVal = true; - aiPromptVal = String(a.params?.prompt || a.params?.custom_prompt || ''); - aiVariantKeyVal = String(a.params?.variant_key || 'ai_summary'); - } - if (a.type === 'ai_agent_action') { - aiAgentVal = true; - aiAgentPromptVal = String(a.params?.prompt || ''); - if (Array.isArray(a.params?.allowed_actions)) { - aiAgentAllowedVal = a.params.allowed_actions.map(String); - } - } - }); - - setBuilderActions({ - scrapeFullText: scrape, - markStarred: starred, - markRead: read, - setVariant: setVar, - variantKey: vKey, - variantValue: vVal, - contentType: cType, - aiEnrich: aiEnrichVal, - aiPrompt: aiPromptVal || aiSettings.prompts?.[0]?.prompt || '', - aiVariantKey: aiVariantKeyVal, - aiAgentAction: aiAgentVal, - aiAgentPrompt: aiAgentPromptVal, - aiAgentAllowedActions: aiAgentAllowedVal, - }); - - setSaveDropdownOpen(false); - setShowRuleModal(true); - }; - - const handleSaveRule = async (e?: React.FormEvent, applyToExisting: boolean = false) => { - if (e) e.preventDefault(); - if (!ruleName.trim()) { - return; // Inline validation below the input field already shows the error - } - - // Compile Conditions - const compiledConditions: RuleCondition[] = builderConditions.map((cond) => { - let finalVal: any = cond.value; - if (cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in')) { - finalVal = cond.selectedFeedIds; - } else if (cond.field === 'category_id' && (cond.operator === 'in' || cond.operator === 'not_in')) { - finalVal = cond.selectedCatIds; - } - return { - field: cond.field, - operator: cond.operator, - value: finalVal, - }; - }); - - // Compile Actions - const compiledActions: RuleAction[] = []; - if (builderActions.scrapeFullText) { - compiledActions.push({ type: 'readability_scrape' }); - } - if (builderActions.markStarred) { - compiledActions.push({ type: 'mark_starred' }); - } - if (builderActions.markRead) { - compiledActions.push({ type: 'mark_read' }); - } - if (builderActions.setVariant && builderActions.variantKey.trim()) { - compiledActions.push({ - type: 'set_variant', - params: { - variant_key: builderActions.variantKey.trim(), - value: builderActions.variantValue, - content_type: builderActions.contentType || 'text', - }, - }); - } - if (builderActions.aiEnrich) { - compiledActions.push({ - type: 'ai_enrich', - params: { - variant_key: builderActions.aiVariantKey.trim() || 'ai_summary', - prompt: builderActions.aiPrompt, - content_type: 'text', - }, - }); - } - if (builderActions.aiAgentAction) { - compiledActions.push({ - type: 'ai_agent_action', - params: { - prompt: builderActions.aiAgentPrompt, - allowed_actions: builderActions.aiAgentAllowedActions, - }, - }); - } - - if (compiledActions.length === 0) { - alert('Please select at least one action to execute.'); - return; - } - - setSaveLoading(true); - setSaveDropdownOpen(false); - try { - let targetRuleId = editingRuleId; - if (editingRuleId) { - await api.updateRule(editingRuleId, { - name: ruleName.trim(), - trigger: ruleTrigger, - match_mode: ruleMatchMode, - conditions: compiledConditions, - actions: compiledActions, - is_active: true, - }); - } else { - const created = await api.createRule({ - name: ruleName.trim(), - trigger: ruleTrigger, - match_mode: ruleMatchMode, - conditions: compiledConditions, - actions: compiledActions, - }); - targetRuleId = created.id; - } - - if (applyToExisting && targetRuleId) { - const res = await api.applyRule(targetRuleId); - setMessage(`Rule "${ruleName}" saved and applied to ${res.matched_count} matching post(s).`); - } else { - setMessage(`Rule "${ruleName}" ${editingRuleId ? 'updated' : 'created'} successfully.`); - } - - setShowRuleModal(false); - loadRules(); - setTimeout(() => setMessage(null), 3500); - } catch (err: any) { - alert(err.message || 'Failed to save rule'); - } finally { - setSaveLoading(false); - } - }; - - const handleToggleRuleActive = async (rule: Rule) => { - try { - await api.updateRule(rule.id, { - name: rule.name, - trigger: rule.trigger, - match_mode: rule.match_mode, - conditions: rule.conditions, - actions: rule.actions, - is_active: !rule.is_active, - }); - loadRules(); - } catch (err: any) { - alert(err.message || 'Failed to toggle rule'); - } - }; - - const getNaturalLanguageSummary = (rule: Rule) => { - const triggerText = rule.trigger === 'on_variant_updated' ? 'Variant Updates' : 'New Post Ingested'; - const condTexts: string[] = []; - - if (rule.conditions && rule.conditions.length > 0) { - rule.conditions.forEach((c) => { - if (c.field === 'feed_id') { - const ids = Array.isArray(c.value) ? c.value : String(c.value).split(','); - if (c.operator === 'in') { - condTexts.push(`in ${ids.length} Feeds`); - } else if (c.operator === 'not_in') { - condTexts.push(`except ${ids.length} Feeds`); - } else if (c.operator === 'eq' || c.operator === '==') { - const f = feeds.find((item) => item.id === c.value); - condTexts.push(`in feed "${f?.title || c.value}"`); - } else { - condTexts.push(`feed ${c.operator} "${c.value}"`); - } - } else if (c.field === 'category_id') { - condTexts.push(`Category ${c.operator} "${c.value}"`); - } else if (c.field === 'title') { - condTexts.push(`Title ${c.operator.replace('_', ' ')} "${c.value}"`); - } else if (c.field === 'content' || c.field === 'raw_content') { - condTexts.push(`Content ${c.operator.replace('_', ' ')} "${c.value}"`); - } else if (c.field.startsWith('variants.')) { - condTexts.push(`${c.field} ${c.operator.replace('_', ' ')} "${c.value || ''}"`); - } else { - condTexts.push(`${c.field} ${c.operator} "${c.value}"`); - } - }); - } - - const condJoined = - condTexts.length > 0 - ? rule.match_mode === 'any' - ? condTexts.join(' OR ') - : condTexts.join(' AND ') - : 'All Feeds (Global)'; - - const actionTexts: string[] = []; - rule.actions?.forEach((a) => { - if (a.type === 'readability_scrape') actionTexts.push('Scrape Full Text'); - else if (a.type === 'mark_starred') actionTexts.push('Star'); - else if (a.type === 'mark_read') actionTexts.push('Mark Read'); - else if (a.type === 'set_variant') actionTexts.push(`Set Variant [${a.params?.variant_key || 'tags'}]`); - else actionTexts.push(a.type); - }); - - return { - trigger: triggerText, - conditions: condJoined, - actions: actionTexts.join(', ') || 'No Action', - }; - }; - - // Filter feeds - const filteredFeeds = feeds.filter( - (f) => - f.title.toLowerCase().includes(feedSearch.toLowerCase()) || - f.feed_url.toLowerCase().includes(feedSearch.toLowerCase()) || - (f.category_title && f.category_title.toLowerCase().includes(feedSearch.toLowerCase())) - ); - - const openEditModal = (feed: Feed) => { - setEditingFeed(feed); - setEditTitle(feed.title); - setEditCategoryId(feed.category_id || ''); - setEditInterval(feed.refresh_interval_minutes || 30); - setEditTimeout(feed.timeout_seconds || 10); - }; - - const handleSaveFeed = async (e: React.FormEvent) => { - e.preventDefault(); - if (!editingFeed) return; - setEditSaving(true); - try { - await api.updateFeed(editingFeed.id, { - title: editTitle.trim() || undefined, - category_id: editCategoryId || '', - refresh_interval_minutes: Number(editInterval), - timeout_seconds: Number(editTimeout), - }); - setEditingFeed(null); - onRefreshData(); - setMessage(`Updated "${editTitle || editingFeed.title}" settings.`); - setTimeout(() => setMessage(null), 3500); - } catch (err: any) { - alert(err.message || 'Failed to update feed'); - } finally { - setEditSaving(false); - } - }; - - const handleCreateCategory = async (e: React.FormEvent) => { - e.preventDefault(); - if (!newCatTitle.trim()) return; - setLoading(true); - try { - await api.createCategory(newCatTitle.trim()); - setNewCatTitle(''); - onRefreshData(); - setMessage(`Category "${newCatTitle}" created.`); - setTimeout(() => setMessage(null), 3000); - } catch (err: any) { - alert(err.message || 'Failed to create category'); - } finally { - setLoading(false); - } - }; - - const handleConfirmDelete = async () => { - if (!deleteConfirm) return; - try { - if (deleteConfirm.type === 'feed') { - await api.deleteFeed(deleteConfirm.id); - setMessage(`Unsubscribed from "${deleteConfirm.title}".`); - } else if (deleteConfirm.type === 'category') { - await api.deleteCategory(deleteConfirm.id); - setMessage(`Deleted category "${deleteConfirm.title}".`); - } else if (deleteConfirm.type === 'rule') { - await api.deleteRule(deleteConfirm.id); - setMessage(`Deleted rule "${deleteConfirm.title}".`); - loadRules(); - } - onRefreshData(); - setTimeout(() => setMessage(null), 3000); - } catch (err: any) { - alert(err.message || 'Failed to delete'); - } finally { - setDeleteConfirm(null); - } - }; - - const handleRefreshFeed = async (feedId: string) => { - try { - await api.refreshFeed(feedId); - onRefreshData(); - setMessage('Feed refreshed successfully.'); - setTimeout(() => setMessage(null), 2500); - } catch (err: any) { - alert(err.message || 'Failed to refresh feed'); - } - }; - - const handleImportOPML = async () => { - if (!opmlFile) return; - setOpmlLoading(true); - setOpmlResult(null); - try { - const res = await api.importOPML(opmlFile); - setOpmlResult(`Successfully imported ${res.imported_count} new feeds (out of ${res.total_parsed} parsed).`); - onRefreshData(); - } catch (err: any) { - alert(err.message || 'Failed to import OPML'); - } finally { - setOpmlLoading(false); - } - }; - - const handleExportOPML = () => { - window.open(api.exportOPMLUrl(), '_blank'); - }; - - return ( -
- {/* Settings Tab Navigation (Responsive sidebar on desktop / top chips on mobile) */} -
- - - - - - - - - - - - - -
- - {/* Main Settings Content Area */} -
- {message && ( -
- - {message} -
- )} - - {/* 1. Subscriptions Management Tab */} - {activeTab === 'feeds' && ( -
-
-
-

- Subscription Management -

-

- Configure background pulling interval, request timeouts, and categories per feed. -

-
- - -
- -
-
- - setFeedSearch(e.target.value)} - /> -
- -
- - - - - - - - - - - - {filteredFeeds.map((feed) => ( - - - - - - - - ))} - -
FEEDCATEGORYSCHEDULESTATUSACTIONS
-
- {feed.title} -
-
- {feed.feed_url} -
-
- {feed.category_title ? ( - {feed.category_title} - ) : ( - — - )} - -
- - {feed.refresh_interval_minutes || 30}m - - - {feed.timeout_seconds || 10}s - -
-
- {feed.fetch_error ? ( - - ) : ( - - ● Healthy - - )} - -
- - - -
-
-
-
-
- )} - - {/* 2. Categories Tab */} - {activeTab === 'categories' && ( -
-
-

- Category Management -

-

- Create, organize, and delete category folders. -

-
- - {/* Create Category */} -
-

Add New Category

-
- setNewCatTitle(e.target.value)} - style={{ maxWidth: 360 }} - required - /> - -
-
- - {/* Existing Categories */} -
-

Existing Categories ({categories.length})

-
- {categories.map((cat) => ( -
-
- - {cat.title} - {cat.feed_count || 0} feeds -
- - -
- ))} -
-
-
- )} - - {/* Rules & Automation Hooks Tab */} - {activeTab === 'rules' && ( -
-
-
-

- Automation Rules & Pipeline Hooks -

-

- Filter feeds and automatically trigger enrichments (Readability Scrape, Star, Read, Tagging) on post arrival or variant updates. -

-
- - -
- - {rulesLoading ? ( -
- -

Loading rules...

-
- ) : rules.length === 0 ? ( -
-
⚡
-

No Automation Rules Defined

-

- Create rules to automatically scrape full-text articles across your favorite subscriptions, tag topics, or auto-triage feeds. -

- -
- ) : ( -
- {rules.map((rule) => { - const summary = getNaturalLanguageSummary(rule); - return ( -
-
-
-
- -
- -
-
- - {rule.name} - - - {summary.trigger} - - {!rule.is_active && ( - (Disabled) - )} -
- - {/* Natural Language Sentence Preview */} -
- When {summary.trigger}{' '} - matching{' '} - {summary.conditions}{' '} - ➜ Then{' '} - {summary.actions} -
-
-
- -
- - - -
-
-
- ); - })} -
- )} -
- )} - - {/* AI & Integrations Tab */} - {activeTab === 'ai' && ( -
-
-

- AI & Integrations -

-

- Configure OpenAI-compatible LLM providers for automated summarization, keyword extraction, and autonomous triage. -

-
- - {aiLoading && ( -
- - Loading AI configuration... -
- )} - - {/* AI Provider Config Card */} -
-
-
- -

LLM Provider Settings

-
- -
- - {/* Provider Quick Presets */} -
- -
- {[ - { id: 'openai', label: 'OpenAI', base: 'https://api.openai.com/v1', model: 'gpt-4o-mini' }, - { id: 'deepseek', label: 'DeepSeek', base: 'https://api.deepseek.com/v1', model: 'deepseek-chat' }, - { id: 'gemini', label: 'Google Gemini', base: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash' }, - { id: 'ollama', label: 'Ollama (Local)', base: 'http://localhost:11434/v1', model: 'llama3.2' }, - { id: 'custom', label: 'Custom Endpoint', base: '', model: '' }, - ].map((p) => ( - - ))} -
-
- -
-
-
- - setAiSettings({ ...aiSettings, base_url: e.target.value })} - placeholder="https://api.openai.com/v1" - required - /> -
- -
- - setAiSettings({ ...aiSettings, model: e.target.value })} - placeholder="e.g. gpt-4o-mini, deepseek-chat, llama3.2" - required - /> -
-
- -
-
- - -
- setAiSettings({ ...aiSettings, api_key: e.target.value })} - placeholder={aiSettings.provider === 'ollama' ? 'Optional for local Ollama' : 'sk-...'} - /> -
- Stored securely in local SQLite database. Keys are masked during display. -
-
- -
-
-
- -
- setAiSettings({ ...aiSettings, temperature: parseFloat(e.target.value) })} - style={{ width: '100%', accentColor: '#10b981' }} - /> -
- 0.0 (Precise / Deterministic) - 1.0 (Creative) -
-
- -
- - setAiSettings({ ...aiSettings, max_tokens: parseInt(e.target.value) || 1000 })} - min={100} - max={8192} - /> -
-
- - {/* Connection Test Banner */} - {aiTestResult && ( -
-
- {aiTestResult.success ? '✓ Connection Verified' : '✕ Connection Error'}:{' '} - {aiTestResult.message} -
- {aiTestResult.latency_ms > 0 && ( - {aiTestResult.latency_ms}ms - )} -
- )} - -
- - -
-
-
- - {/* Prompt Template Library Card */} -
-
-
- -

Prompt Template Library

-
- -
- -
- {aiSettings.prompts.map((p) => ( -
-
-
- {p.name} - {p.is_default && ( - - Default - - )} -
-
- - {!p.is_default && ( - - )} -
-
- {p.description && ( -
- {p.description} -
- )} -
-                      {p.prompt}
-                    
-
- ))} -
-
-
- )} - - {/* 3. Viewer Preferences Tab */} - {activeTab === 'viewer' && ( -
-
-

- Viewer & Reading Preferences -

-

- Configure reading behavior, auto-read triggers, and article view options. -

-
- -
-

Auto-Mark as Read

-

- Control how articles are automatically marked as read during your reading session. -

- -
- -
-
-
- )} - - {/* 4. OPML Migration Tab */} - {activeTab === 'opml' && ( -
-
-

- OPML Migration & Backup -

-

- Easily import from external RSS services or download a complete backup. -

-
- - {opmlResult && ( -
- {opmlResult} -
- )} - -
-

Import Subscriptions

-

- Select an OPML XML file exported from Miniflux, Feedly, or NetNewsWire. -

- -
document.getElementById('settings-opml-file')?.click()} - > - { - if (e.target.files && e.target.files[0]) { - setOpmlFile(e.target.files[0]); - } - }} - /> - -
- {opmlFile ? opmlFile.name : 'Click to select OPML file'} -
-
- - {opmlFile && ( - - )} -
- -
-

Export Subscriptions

-

- Download an OPML 2.0 XML file containing all your {feeds.length} subscribed feeds and folder trees. -

- - -
-
- )} - - {/* 4. Account & System Tab */} - {activeTab === 'account' && ( -
-
-

- Account & System Health -

-

- Single-user gateway credentials and storage metrics. -

-
- -
-

Admin Profile

-
-
- Username: {currentUser.username} -
-
- User ID: {currentUser.id} -
-
- Access Mode: Single-User RSS Information Gateway -
-
-
- -
-

- - Change Password -

-

- Update your single-user administrator password. Minimum 6 characters required. -

- - {passwordSuccess && ( -
- - {passwordSuccess} -
- )} - - {passwordError && ( -
- - {passwordError} -
- )} - -
-
- - setOldPassword(e.target.value)} - required - /> -
- -
- - setNewPassword(e.target.value)} - required - /> -
- -
- - setConfirmPassword(e.target.value)} - required - /> -
- - -
-
- -
-

Storage & Engine Stats

-
-
- Database Engine: SQLite 3 with WAL (Write-Ahead Logging) Mode -
-
- Total Subscribed Feeds: {stats?.total_feeds || feeds.length} -
-
- Total Unread Articles: {stats?.total_unread || 0} -
-
- Total Starred Articles: {stats?.total_starred || 0} -
-
-
-
- )} -
- - {/* Edit Feed Modal (Pulling interval, timeout, category, title) */} - {editingFeed && ( -
setEditingFeed(null)}> -
e.stopPropagation()}> -
-

Edit Subscription Settings

- -
- -
-
-
- - setEditTitle(e.target.value)} - required - /> -
- -
- - -
- -
- - -
- -
-
- - -
- -
- - -
-
-
- -
- - -
-
-
-
- )} - - {/* Debug Logs Modal (Persistent error inspector) */} - {debugFeed && ( -
setDebugFeed(null)}> -
e.stopPropagation()}> -
-
- -

Feed Fetch Diagnostics

-
- -
- -
-
- Feed: {debugFeed.title} -
-
- URL: {debugFeed.feed_url} -
- -
- -
-                  {debugFeed.fetch_error || 'No error recorded.'}
-                
-
-
- -
- - - -
-
-
- )} - - {/* Solid Confirmation Modal for Deletion (Never auto-dismisses) */} - {deleteConfirm && ( -
setDeleteConfirm(null)}> -
e.stopPropagation()}> -
-

Confirm Deletion

- -
- -
- Are you sure you want to {deleteConfirm.type === 'feed' ? 'unsubscribe from' : deleteConfirm.type === 'category' ? 'delete category' : 'delete rule'}{' '} - "{deleteConfirm.title}"? - {deleteConfirm.type === 'category' && ( -

- Feeds inside this category will remain and become uncategorized. -

- )} -
- -
- - -
-
-
- )} - - {/* Create / Edit Automation Rule Modal */} - {showRuleModal && ( -
setShowRuleModal(false)}> -
{ - e.stopPropagation(); - if (saveDropdownOpen) setSaveDropdownOpen(false); - }} - > -
-
- -

- {editingRuleId ? 'Edit Automation Rule' : 'Create Automation Rule'} -

-
- -
- -
handleSaveRule(e, false)} - style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }} - > -
- {/* Rule Name */} -
- - setRuleName(e.target.value)} - style={!ruleName.trim() ? { borderColor: 'var(--danger)' } : undefined} - /> - {!ruleName.trim() && ( -
- Please provide a descriptive rule name. -
- )} -
- - {/* LEVEL 1: TRIGGER SELECTION */} -
-
- - 1 - - - When This Trigger Occurs - -
- -
- - - -
- - {ruleTrigger === 'on_variant_updated' && ( -
-
- - -
- -
- - setTargetVariantKey(e.target.value)} - /> -
-
- )} -
- - {/* LEVEL 2: CONDITIONS & FILTER SCOPE */} -
-
-
- - 2 - - - Match Conditions (Filter Scope) - -
- -
- Match: - -
-
- -
- {builderConditions.map((cond, idx) => ( -
-
- {/* Field Selector */} - - - {/* Contextual Operator Selector */} - - - {/* Delete condition button */} - {builderConditions.length > 1 && ( - - )} -
- - {/* Contextual Value Input */} - {cond.field === 'feed_id' && (cond.operator === 'in' || cond.operator === 'not_in') ? ( -
-
-
- - setFeedPickerSearch(e.target.value)} - style={{ - border: 'none', - background: 'transparent', - outline: 'none', - fontSize: '0.75rem', - width: '100%', - color: 'var(--text-main)', - }} - /> -
- -
- - - -
-
- -
- {cond.selectedFeedIds.length} of {feeds.length} feeds selected -
- -
- {feeds - .filter((f) => f.title.toLowerCase().includes(feedPickerSearch.toLowerCase())) - .map((feed) => { - const isChecked = cond.selectedFeedIds.includes(feed.id); - return ( - - ); - })} -
-
- ) : cond.field === 'feed_id' ? ( - - ) : cond.field === 'category_id' ? ( -
- {categories.map((cat) => { - const isChecked = cond.selectedCatIds.includes(cat.id); - return ( - - ); - })} -
- ) : cond.field === 'published_at' ? ( -
- { - const updated = [...builderConditions]; - updated[idx] = { ...cond, value: e.target.value }; - setBuilderConditions(updated); - }} - style={{ width: 120 }} - /> - - {cond.operator === 'older_than_days' ? 'days' : 'hours'} - -
- ) : cond.field === 'is_read' || cond.field === 'is_starred' ? ( - - ) : cond.operator === 'exists' || cond.operator === 'not_exists' ? ( -
- Checks if the variant key exists and is non-empty. -
- ) : ( - { - const updated = [...builderConditions]; - updated[idx] = { ...cond, value: e.target.value }; - setBuilderConditions(updated); - }} - /> - )} -
- ))} - - -
-
- - {/* LEVEL 3: ACTION EXECUTION PIPELINE */} -
-
- - 3 - - - Execute Action Pipeline - -
- -
- - - - - - - - - {/* AI Variant Enrichment Action */} -