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 (
-
-
+
@@ -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
-
-
-
-
-
-
- );
-};
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}
-
- >
- )}
-
-
-
-
-
-
-
-
-
- );
-};
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}
-
- >
- )}
-
-
-
-
- {/* 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
-
-
-
-
-
-
-
-
- |
- SHORTCUT
- |
-
- ACTION
- |
-
-
-
- {shortcuts.map((sc, i) => (
-
- |
-
- {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 (
-
-
-
-
RSS-Driven Information Gateway
-
- {error &&
{error}
}
-
-
-
-
- );
-};
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)}
- />
-
-
-
-
-
-
- | FEED |
- CATEGORY |
- SCHEDULE |
- STATUS |
- ACTIONS |
-
-
-
- {filteredFeeds.map((feed) => (
-
- |
-
- {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 */}
-
-
- {/* 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 ? (
-
- ) : 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) => (
-
- ))}
-
-
-
-
-
-
- {/* 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 && (
-
- )}
-
-
-
-
-
-
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
-
-
-
-
-
-
- )}
-
- {/* 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'}
-
-
-
-
-
-
-
-
- )}
-
- {/* Edit Prompt Template Modal */}
- {editingPrompt && (
-
setEditingPrompt(null)}>
-
e.stopPropagation()}>
-
-
-
-
Edit Prompt Template
-
-
-
-
-
-
-
- )}
-
- );
-};
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx
deleted file mode 100644
index eba92e5..0000000
--- a/web/src/components/Sidebar.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import React, { useState } from 'react';
-import { Category, Feed, FilterState, Stats } from '../types';
-import {
- Inbox,
- Star,
- Layers,
- Folder,
- FolderOpen,
- Rss,
- ChevronRight,
- ChevronDown,
-} from 'lucide-react';
-
-interface SidebarProps {
- filter: FilterState;
- onFilterChange: (newFilter: Partial) => void;
- categories: Category[];
- feeds: Feed[];
- stats: Stats | null;
-}
-
-export const Sidebar: React.FC = ({
- filter,
- onFilterChange,
- categories,
- feeds,
- stats,
-}) => {
- const [collapsedCats, setCollapsedCats] = useState>({});
-
- const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => {
- e.stopPropagation();
- setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] }));
- };
-
- // Group feeds by category
- const categorizedFeeds: Record = {};
- const uncategorizedFeeds: Feed[] = [];
-
- feeds.forEach((f) => {
- if (f.category_id) {
- if (!categorizedFeeds[f.category_id]) {
- categorizedFeeds[f.category_id] = [];
- }
- categorizedFeeds[f.category_id].push(f);
- } else {
- uncategorizedFeeds.push(f);
- }
- });
-
- return (
-
- {/* Core Navigation Items */}
-
- onFilterChange({
- view: 'inbox',
- feedId: undefined,
- categoryId: undefined,
- })
- }
- >
-
-
- Inbox
-
- {stats && stats.total_unread > 0 && (
-
{stats.total_unread}
- )}
-
-
-
- onFilterChange({
- view: 'starred',
- feedId: undefined,
- categoryId: undefined,
- })
- }
- >
-
-
- Starred
-
- {stats && stats.total_starred > 0 && (
-
{stats.total_starred}
- )}
-
-
-
- onFilterChange({
- view: 'all',
- feedId: undefined,
- categoryId: undefined,
- })
- }
- >
-
-
- All Articles
-
-
-
- {/* Categories Section */}
- {categories.length > 0 && (
-
-
- Categories
-
-
- {categories.map((cat) => {
- const catFeeds = categorizedFeeds[cat.id] || [];
- const isCollapsed = collapsedCats[cat.id];
- const isCatActive = filter.categoryId === cat.id && !filter.feedId;
- const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0);
-
- return (
-
-
- onFilterChange({
- categoryId: cat.id,
- feedId: undefined,
- })
- }
- >
-
-
- {isCollapsed ? : }
- {cat.title}
-
-
- {catUnread > 0 &&
{catUnread}}
-
-
- {/* Category feeds */}
- {!isCollapsed &&
- catFeeds.map((feed) => {
- const isFeedActive = filter.feedId === feed.id;
- return (
-
- onFilterChange({
- feedId: feed.id,
- categoryId: undefined,
- })
- }
- >
-
-
-
- {feed.title}
-
-
-
- {feed.unread_count > 0 && (
-
{feed.unread_count}
- )}
-
- );
- })}
-
- );
- })}
-
- )}
-
- {/* Uncategorized Feeds */}
- {uncategorizedFeeds.length > 0 && (
-
-
- Feeds
-
-
- {uncategorizedFeeds.map((feed) => {
- const isFeedActive = filter.feedId === feed.id;
- return (
-
- onFilterChange({
- feedId: feed.id,
- categoryId: undefined,
- })
- }
- >
-
-
-
- {feed.title}
-
-
-
- {feed.unread_count > 0 && (
-
{feed.unread_count}
- )}
-
- );
- })}
-
- )}
-
- );
-};
diff --git a/web/src/components/ThreePaneView.tsx b/web/src/components/ThreePaneView.tsx
deleted file mode 100644
index 10ba828..0000000
--- a/web/src/components/ThreePaneView.tsx
+++ /dev/null
@@ -1,569 +0,0 @@
-import React, { useEffect, useRef, useState } from 'react';
-import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
-import { Sidebar } from './Sidebar';
-import { formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
-import { api } from '../services/api';
-import {
- Star,
- CheckCircle2,
- ExternalLink,
- Share2,
- Clock,
- User as UserIcon,
- CheckCheck,
- FileText,
- Zap,
- Sparkles,
- Volume2,
- Loader2,
- Copy,
-} from 'lucide-react';
-
-interface ThreePaneViewProps {
- filter: FilterState;
- onFilterChange: (newFilter: Partial) => void;
- categories: Category[];
- feeds: Feed[];
- stats: Stats | null;
- entries: FeedEntry[];
- selectedIndex: number;
- activeArticle?: FeedEntry | null;
- onSelectEntry: (index: number) => void;
- onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
- onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
- onMarkAllRead: () => void;
- onUpdateEntry?: (entry: FeedEntry) => void;
- sidebarCollapsed: boolean;
-}
-
-export const ThreePaneView: React.FC = ({
- filter,
- onFilterChange,
- categories,
- feeds,
- stats,
- entries,
- selectedIndex,
- activeArticle,
- onSelectEntry,
- onToggleRead,
- onToggleStar,
- onMarkAllRead,
- onUpdateEntry,
- sidebarCollapsed,
-}) => {
- const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
- const readerPaneRef = useRef(null);
- const summaryCardRef = useRef(null);
- const [selectedVariantKey, setSelectedVariantKey] = useState('original');
- const [showAISummary, setShowAISummary] = useState(true);
- const [isScraping, setIsScraping] = useState(false);
-
- const selectedEntry = activeArticle || entries[selectedIndex] || entries[0];
-
- // Auto-scroll list to active card
- useEffect(() => {
- if (selectedIndex >= 0 && selectedIndex < entries.length) {
- const el = cardRefs.current[selectedIndex];
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- }
- }, [selectedIndex, entries.length]);
-
- // Reset variant selection when article changes
- useEffect(() => {
- if (selectedEntry?.variants?.['readability_full_text']) {
- // If full text is available, you can choose to stay on current or default to original
- }
- }, [selectedEntry?.id]);
-
- // When switching selected article, reset reader scroll position to top and handle 2s timer for short content
- useEffect(() => {
- if (readerPaneRef.current) {
- readerPaneRef.current.scrollTop = 0;
- }
-
- if (!selectedEntry || selectedEntry.is_read) return;
-
- const autoReadBottom = localStorage.getItem('rsspipe_auto_read_bottom') !== 'false';
- if (!autoReadBottom) return;
-
- let timer: ReturnType | null = null;
-
- // Small delay to allow DOM layout calculation
- const timeout = setTimeout(() => {
- if (readerPaneRef.current) {
- const { scrollHeight, clientHeight } = readerPaneRef.current;
- // Condition 2: If content is < 1 page (not scrollable, already at bottom), mark as read after 2 seconds
- if (scrollHeight <= clientHeight + 35) {
- timer = setTimeout(() => {
- if (selectedEntry && !selectedEntry.is_read) {
- onToggleRead(selectedEntry);
- }
- }, 2000);
- }
- }
- }, 100);
-
- return () => {
- clearTimeout(timeout);
- if (timer) clearTimeout(timer);
- };
- }, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
-
- // Reading Detection 1: Any active scroll on content >= 1 page immediately marks as read
- const handleReaderScroll = (e: React.UIEvent) => {
- if (!selectedEntry || selectedEntry.is_read) return;
- const { scrollTop } = e.currentTarget;
- if (scrollTop > 0) {
- onToggleRead(selectedEntry);
- }
- };
-
- // Reading Detection 2: Clicking anywhere inside the content canvas marks as read
- const handleContentPaneClick = () => {
- if (selectedEntry && !selectedEntry.is_read) {
- onToggleRead(selectedEntry);
- }
- };
-
- const handleScrapeFullText = async () => {
- if (!selectedEntry || isScraping) return;
- setIsScraping(true);
- try {
- const updated = await api.scrapeEntry(selectedEntry.id);
- if (onUpdateEntry) {
- onUpdateEntry(updated);
- }
- setSelectedVariantKey('readability_full_text');
- } catch (err: any) {
- alert(`Scraping failed: ${err.message || err}`);
- } finally {
- setIsScraping(false);
- }
- };
-
- const getListTitle = () => {
- if (filter.search) return `Search: "${filter.search}"`;
- if (filter.feedId) {
- const f = feeds.find((item) => item.id === filter.feedId);
- return f ? f.title : 'Feed';
- }
- if (filter.categoryId) {
- const c = categories.find((item) => item.id === filter.categoryId);
- return c ? c.title : 'Category';
- }
- if (filter.view === 'starred') return 'Starred';
- if (filter.view === 'all') return 'All Articles';
- return 'Inbox';
- };
-
- const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
- const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
- const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
-
- const getRenderedContent = () => {
- if (!selectedEntry) return '';
- if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
- return selectedEntry.variants['readability_full_text'].text_value || '';
- }
- return selectedEntry.content || selectedEntry.raw_content || '';
- };
-
- return (
-
- {/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */}
-
- {!sidebarCollapsed && (
-
- )}
-
-
- {/* Pane 2: Article List (Reeder 4 minimal style) */}
-
-
-
- {getListTitle()}
-
- {entries.length}
-
-
-
-
-
-
-
- {entries.length === 0 ? (
-
-
✨
-
Inbox Zero
-
No articles to read here.
-
- ) : (
- entries.map((entry, index) => {
- const isSelected = index === selectedIndex;
- const snippet = stripHtml(entry.content || entry.raw_content, 100);
- const relativeTime = formatRelativeDate(entry.published_at);
-
- return (
-
(cardRefs.current[index] = el)}
- className={`article-item-card ${isSelected ? 'selected' : ''} ${
- entry.is_read ? 'is-read' : ''
- }`}
- onClick={() => onSelectEntry(index)}
- >
-
-
{entry.feed_title}
-
-
{relativeTime}
- {!entry.is_read &&
}
-
-
-
-
{entry.title || 'Untitled'}
-
- {snippet &&
{snippet}
}
-
- {(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
-
- {getEntryTags(entry.variants).map((tag) => (
- 🏷️ {tag}
- ))}
- {entry.variants?.['ai_summary'] && (
-
- ✨ AI Summary
-
- )}
-
- )}
-
- );
- })
- )}
-
-
-
- {/* Pane 3: Distraction-Free Reading Canvas */}
-
- {selectedEntry ? (
- <>
- {/* Top Toolbar */}
-
e.stopPropagation()}>
-
-
-
-
-
- {/* Sliding Ball Capsule Toggle Button */}
-
-
- {hasAISummary && (
-
- )}
-
- {hasTTS && (
-
- )}
-
-
-
-
-
- {/* Reading Content */}
-
-
-
-
- {selectedEntry.feed_title}
-
- •
-
-
- {new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}
-
- •
- {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}
- {selectedEntry.author && (
- <>
- •
-
- {selectedEntry.author}
-
- >
- )}
-
-
-
-
- {getEntryTags(selectedEntry.variants).length > 0 && (
-
- {getEntryTags(selectedEntry.variants).map((tag) => (
- 🏷️ {tag}
- ))}
-
- )}
-
-
- {/* Audio player if TTS variant is selected */}
- {selectedVariantKey === 'tts' && (
-
- )}
-
- {/* AI Summary Card if present and visible */}
- {selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
-
-
-
-
- AI Summary & Takeaways
- {selectedEntry.variants.ai_summary.metadata?.model && (
-
- {String(selectedEntry.variants.ai_summary.metadata.model)}
-
- )}
-
-
-
-
-
-
-
-
- )}
-
-
-
- >
- ) : (
-
-
📖
-
No Article Selected
-
Select an article from the list or press 'j' / 'k' to read.
-
- )}
-
-
- );
-};
-
diff --git a/web/src/components/WaterfallView.tsx b/web/src/components/WaterfallView.tsx
deleted file mode 100644
index a19c854..0000000
--- a/web/src/components/WaterfallView.tsx
+++ /dev/null
@@ -1,575 +0,0 @@
-import React, { useState, useEffect, useRef } from 'react';
-import { api } from '../services/api';
-import { Category, FeedEntry, FilterState } from '../types';
-import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime, getEntryTags, renderMarkdownText } from '../utils';
-import {
- Star,
- Inbox,
- Star as StarIcon,
- Layers,
- Rss,
- ArrowLeft,
- Share2,
- ExternalLink,
- CheckCircle2,
- Clock,
- User as UserIcon,
- FileText,
- Zap,
- Loader2,
- Sparkles,
- Volume2,
- Copy,
-} from 'lucide-react';
-
-interface WaterfallViewProps {
- filter: FilterState;
- onFilterChange: (newFilter: Partial) => void;
- categories: Category[];
- entries: FeedEntry[];
- onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
- onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
- onUpdateEntry?: (entry: FeedEntry) => void;
-}
-
-export const WaterfallView: React.FC = ({
- filter,
- onFilterChange,
- categories,
- entries,
- onToggleRead,
- onToggleStar,
- onUpdateEntry,
-}) => {
- const [selectedEntry, setSelectedEntry] = useState(null);
- const [selectedVariantKey, setSelectedVariantKey] = useState('original');
- const [showAISummary, setShowAISummary] = useState(true);
- const [isScraping, setIsScraping] = useState(false);
- const readerScrollRef = useRef(null);
- const summaryCardRef = useRef(null);
-
- // Reset variant selection when selected article changes
- useEffect(() => {
- setSelectedVariantKey('original');
- }, [selectedEntry?.id]);
-
- const handleScrapeFullText = async () => {
- if (!selectedEntry || isScraping) return;
- setIsScraping(true);
- try {
- const updated = await api.scrapeEntry(selectedEntry.id);
- setSelectedEntry(updated);
- onUpdateEntry?.(updated);
- setSelectedVariantKey('readability_full_text');
- } catch (err: any) {
- alert(`Scraping failed: ${err.message || err}`);
- } finally {
- setIsScraping(false);
- }
- };
-
- const hasReadability = Boolean(selectedEntry?.variants?.['readability_full_text']);
- const hasAISummary = Boolean(selectedEntry?.variants?.['ai_summary']);
- const hasTTS = Boolean(selectedEntry?.variants?.['tts'] || selectedEntry?.variants?.['podcast_tts']);
-
- const getRenderedContent = () => {
- if (!selectedEntry) return '';
- if (selectedVariantKey === 'readability_full_text' && selectedEntry.variants?.['readability_full_text']) {
- return selectedEntry.variants['readability_full_text'].text_value || '';
- }
- return selectedEntry.content || selectedEntry.raw_content || '';
- };
-
- // When card is clicked, open in full-screen reader view and immediately mark as read
- const handleCardClick = (entry: FeedEntry) => {
- setSelectedEntry(entry);
- if (!entry.is_read) {
- onToggleRead(entry);
- }
- };
-
- return (
-
- {/* Top Category Tabs (Xiaohongshu App Style - No Left Sidebar) */}
-
-
-
-
-
-
-
- {/* Categories as Horizontal Chips */}
- {categories.map((cat) => {
- const isActive = filter.categoryId === cat.id;
- return (
-
- );
- })}
-
-
- {/* True Xiaohongshu Waterfall (Masonry Grid with Staggered Heights) */}
-
- {entries.length === 0 ? (
-
-
🎉
-
Inbox Zero
-
No articles to display in this feed.
-
- ) : (
-
- {entries.map((entry) => {
- const coverImg = extractFirstImage(entry.content || entry.raw_content);
- const snippet = stripHtml(entry.content || entry.raw_content, 90);
- const relativeDate = formatRelativeDate(entry.published_at);
-
- return (
-
-
handleCardClick(entry)}
- >
- {/* Card Cover (Xiaohongshu style image / fallback) */}
-
- {coverImg ? (
-

{
- (e.target as HTMLElement).style.display = 'none';
- }}
- />
- ) : (
-
-
-
-
- {entry.feed_title}
-
-
-
- )}
-
-
- {/* Card Body */}
-
-
{entry.title}
- {snippet &&
{snippet}
}
-
- {(getEntryTags(entry.variants).length > 0 || Boolean(entry.variants?.['ai_summary'])) && (
-
- {getEntryTags(entry.variants).map((tag) => (
- 🏷️ {tag}
- ))}
- {entry.variants?.['ai_summary'] && (
-
- ✨ AI Summary
-
- )}
-
- )}
-
- {/* Footer */}
-
-
- {entry.feed_title}
- •
- {relativeDate}
-
-
-
- {!entry.is_read &&
}
-
-
-
-
-
-
- );
- })}
-
- )}
-
-
- {/* Full-Screen Reading View with Back Arrow (Xiaohongshu post reader UX) */}
- {selectedEntry && (
-
- {/* Top Bar with Back Arrow */}
-
-
-
-
- {selectedEntry.feed_title}
-
-
-
- {/* Sliding Ball Capsule Toggle Button */}
-
-
- {hasAISummary && (
-
- )}
-
- {hasTTS && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Reading Body */}
-
-
-
-
-
- {selectedEntry.feed_title}
-
- •
-
-
- {new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}
-
- •
- {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}
- {selectedEntry.author && (
- <>
- •
-
- {selectedEntry.author}
-
- >
- )}
-
-
-
-
- {getEntryTags(selectedEntry.variants).length > 0 && (
-
- {getEntryTags(selectedEntry.variants).map((tag) => (
- 🏷️ {tag}
- ))}
-
- )}
-
-
- {/* AI Summary Card if present and visible */}
- {selectedEntry.variants?.ai_summary?.text_value && showAISummary && (
-
-
-
-
- AI Summary & Takeaways
- {selectedEntry.variants.ai_summary.metadata?.model && (
-
- {String(selectedEntry.variants.ai_summary.metadata.model)}
-
- )}
-
-
-
-
-
-
-
-
- )}
-
- {/* Dynamic Content Variant Rendering */}
- {selectedVariantKey === 'tts' && hasTTS ? (
-
- ) : (
-
- )}
-
-
-
- )}
-
- );
-};
diff --git a/web/src/components/common/AddFeedModal.tsx b/web/src/components/common/AddFeedModal.tsx
new file mode 100644
index 0000000..353c338
--- /dev/null
+++ b/web/src/components/common/AddFeedModal.tsx
@@ -0,0 +1,170 @@
+import React, { useState } from 'react';
+import { Category, Feed } from '../../types';
+import { api } from '../../services/api';
+import { X, Plus, Loader2, AlertCircle } from 'lucide-react';
+
+interface AddFeedModalProps {
+ categories: Category[];
+ onClose: () => void;
+ onFeedAdded: (feed: Feed) => void;
+}
+
+export const AddFeedModal: React.FC = ({
+ categories,
+ onClose,
+ onFeedAdded,
+}) => {
+ const [feedUrl, setFeedUrl] = useState('');
+ const [title, setTitle] = useState('');
+ const [iconUrl, setIconUrl] = 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,
+ iconUrl.trim() || undefined
+ );
+
+ onFeedAdded(newFeed);
+ onClose();
+ } catch (err: any) {
+ setError(err.message || 'Failed to add feed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Add Subscription
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/common/Header.tsx b/web/src/components/common/Header.tsx
new file mode 100644
index 0000000..b09fdf0
--- /dev/null
+++ b/web/src/components/common/Header.tsx
@@ -0,0 +1,302 @@
+import React, { useRef, useEffect, useState } from 'react';
+import { SearchHistoryItem } from '../../types';
+import { api } from '../../services/api';
+import {
+ Rss,
+ Search,
+ Settings,
+ RefreshCw,
+ Plus,
+ HelpCircle,
+ LogOut,
+ History,
+ X,
+ Clock,
+} from 'lucide-react';
+
+interface HeaderProps {
+ search: string;
+ onSearchChange: (val: string) => void;
+ isRefreshing: boolean;
+ onRefreshAll: () => void;
+ onOpenAddFeed: () => void;
+ onOpenSettings: () => void;
+ onOpenShortcuts: () => void;
+ onLogout: () => void;
+}
+
+export const Header: React.FC = ({
+ search,
+ onSearchChange,
+ isRefreshing,
+ onRefreshAll,
+ onOpenAddFeed,
+ onOpenSettings,
+ onOpenShortcuts,
+ onLogout,
+}) => {
+ const searchInputRef = useRef(null);
+ const searchContainerRef = useRef(null);
+
+ const [historyItems, setHistoryItems] = useState([]);
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+
+ // Load search history when input is focused or query changes
+ const loadHistory = async (query?: string) => {
+ try {
+ const items = await api.getSearchHistory(query, 8);
+ setHistoryItems(items || []);
+ } catch {
+ // ignore
+ }
+ };
+
+ useEffect(() => {
+ if (showDropdown) {
+ loadHistory(search);
+ }
+ }, [search, showDropdown]);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ searchContainerRef.current &&
+ !searchContainerRef.current.contains(e.target as Node)
+ ) {
+ setShowDropdown(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Global `/` shortcut for focusing search bar
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (
+ e.key === '/' &&
+ document.activeElement?.tagName !== 'INPUT' &&
+ document.activeElement?.tagName !== 'TEXTAREA'
+ ) {
+ e.preventDefault();
+ searchInputRef.current?.focus();
+ searchInputRef.current?.select();
+ setShowDropdown(true);
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, []);
+
+ const handleSelectKeyword = async (keyword: string) => {
+ onSearchChange(keyword);
+ setShowDropdown(false);
+ try {
+ await api.recordSearch(keyword);
+ } catch {
+ // ignore
+ }
+ };
+
+ const handleKeyDownInSearch = async (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (historyItems.length > 0) {
+ setSelectedIndex((prev) => (prev + 1) % historyItems.length);
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (historyItems.length > 0) {
+ setSelectedIndex((prev) => (prev <= 0 ? historyItems.length - 1 : prev - 1));
+ }
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (selectedIndex >= 0 && historyItems[selectedIndex]) {
+ handleSelectKeyword(historyItems[selectedIndex].keyword);
+ } else if (search.trim()) {
+ setShowDropdown(false);
+ try {
+ await api.recordSearch(search.trim());
+ loadHistory();
+ } catch {
+ // ignore
+ }
+ }
+ } else if (e.key === 'Escape') {
+ setShowDropdown(false);
+ }
+ };
+
+ const handleDeleteHistoryItem = async (e: React.MouseEvent, keyword: string) => {
+ e.stopPropagation();
+ try {
+ await api.deleteSearchHistory(keyword);
+ setHistoryItems((prev) => prev.filter((item) => item.keyword !== keyword));
+ } catch {
+ // ignore
+ }
+ };
+
+ const handleClearAllHistory = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ await api.deleteSearchHistory();
+ setHistoryItems([]);
+ } catch {
+ // ignore
+ }
+ };
+
+ return (
+
+ {/* Leftmost: Brand Icon & Name */}
+
+
+ {/* Center: Autocomplete-Enabled Search Bar */}
+
+
+
{
+ onSearchChange(e.target.value);
+ setSelectedIndex(-1);
+ if (!showDropdown) setShowDropdown(true);
+ }}
+ onFocus={() => {
+ loadHistory(search);
+ setShowDropdown(true);
+ }}
+ onKeyDown={handleKeyDownInSearch}
+ placeholder="Search feeds, titles, or authors..."
+ />
+ {search ? (
+
+ ) : (
+
/
+ )}
+
+ {/* Search Autocomplete History Dropdown */}
+ {showDropdown && historyItems.length > 0 && (
+
+
+
+ Recent Searches
+
+
+
+
+ {historyItems.map((item, idx) => (
+
handleSelectKeyword(item.keyword)}
+ >
+
+
+
+ {item.keyword}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Right: Actions & Settings */}
+
+
+
+
+
+ {/* Settings Button */}
+
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/common/KeyboardShortcutsModal.tsx b/web/src/components/common/KeyboardShortcutsModal.tsx
new file mode 100644
index 0000000..2423067
--- /dev/null
+++ b/web/src/components/common/KeyboardShortcutsModal.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { X, Keyboard } from 'lucide-react';
+
+interface KeyboardShortcutsModalProps {
+ onClose: () => void;
+}
+
+export const KeyboardShortcutsModal: React.FC = ({
+ onClose,
+}) => {
+ const shortcuts = [
+ { key: 'j / ↓', desc: 'Navigate to Next article' },
+ { key: 'k / ↑', desc: 'Navigate to Previous article' },
+ { key: 's', desc: 'Star / Unstar selected article' },
+ { key: 'm / r', desc: 'Toggle Read / Unread status' },
+ { key: 'f', desc: 'Scrape clean full-text via Readability' },
+ { key: 'o / v', desc: 'Open original article in new tab' },
+ { key: '[', desc: 'Collapse / Expand Sidebar' },
+ { key: 'V', desc: 'Toggle View Mode (3-Split ↔ Waterfall)' },
+ { key: 'Shift + A', desc: 'Mark all items in view as read' },
+ { key: '/', desc: 'Focus global search bar' },
+ { key: 'R', desc: 'Refresh all feed subscriptions' },
+ { key: 'A', desc: 'Open Add Feed modal' },
+ { key: '?', desc: 'Show keyboard shortcuts cheat sheet' },
+ ];
+
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
Keyboard Shortcuts
+
+
+
+
+
+
+ {shortcuts.map((sc, i) => (
+
+ {sc.desc}
+ {sc.key}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/common/LoginView.tsx b/web/src/components/common/LoginView.tsx
new file mode 100644
index 0000000..88669d7
--- /dev/null
+++ b/web/src/components/common/LoginView.tsx
@@ -0,0 +1,134 @@
+import React, { useState } from 'react';
+import { api } from '../../services/api';
+import { User } from '../../types';
+import { Rss, Lock, User as UserIcon, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
+
+interface LoginViewProps {
+ onLoginSuccess: (user: User) => void;
+}
+
+export const LoginView: React.FC = ({ 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
+
+
+ Minimalist Feed & Information Engine
+
+
+ {error && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/web/src/components/OPMLModal.tsx b/web/src/components/common/OPMLModal.tsx
similarity index 51%
rename from web/src/components/OPMLModal.tsx
rename to web/src/components/common/OPMLModal.tsx
index df09cbb..6a03d28 100644
--- a/web/src/components/OPMLModal.tsx
+++ b/web/src/components/common/OPMLModal.tsx
@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react';
-import { api } from '../services/api';
-import { X, UploadCloud, Download, Loader2, CheckCircle2 } from 'lucide-react';
+import { api } from '../../services/api';
+import { X, UploadCloud, Download, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
interface OPMLModalProps {
onClose: () => void;
@@ -56,61 +56,70 @@ export const OPMLModal: React.FC = ({
};
return (
-
-
e.stopPropagation()}>
+
+
e.stopPropagation()}>
-
OPML Migration & Backup
-
- {error &&
{error}
}
+ {error && (
+
+ )}
{result && (
-
+
- Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated!
+ Imported {result.imported} new feeds ({result.total} parsed in OPML).
)}
- {/* Import Section */}
-
-
- Import Subscriptions (from Miniflux / Google Reader / OPML)
-
-
- Upload an OPML 2.0 XML file to seamlessly import all your feeds and categories.
-
-
+ {/* Import Dropzone */}
+
+
+ Import Subscriptions (OPML 2.0 XML)
+
e.preventDefault()}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
style={{
- border: '2px dashed var(--border)',
+ border: '2px dashed var(--border-strong)',
borderRadius: 'var(--radius-md)',
- padding: '2rem 1rem',
+ padding: '1.75rem 1rem',
textAlign: 'center',
cursor: 'pointer',
- background: 'var(--bg-main)',
- transition: 'all 0.15s ease',
+ background: 'var(--bg-subtle)',
}}
>
= ({
style={{ display: 'none' }}
onChange={handleFileChange}
/>
-
-
- {file ? file.name : 'Click to browse or drag & drop OPML file'}
-
-
- Supports .opml or .xml
+
+
+ {file ? file.name : 'Click to select or drop .opml / .xml file'}
{file && (
-
-
+
+
{loading ? (
<>
- Importing...
+ Importing...
>
) : (
- <>Import {file.name}>
+ 'Start Import'
)}
@@ -152,18 +151,25 @@ export const OPMLModal: React.FC = ({
{/* Export Section */}
-
-
+
+
Export Subscriptions
-
-
- Download a complete OPML backup of all your subscribed feeds and folders.
+
+
+ Download complete backup of all feeds and categories.
- Export OPML File
+
+ Export OPML File
+
+
+
+ Close
+
+
);
diff --git a/web/src/components/desktop/DesktopArticleList.tsx b/web/src/components/desktop/DesktopArticleList.tsx
new file mode 100644
index 0000000..a63944a
--- /dev/null
+++ b/web/src/components/desktop/DesktopArticleList.tsx
@@ -0,0 +1,283 @@
+import React from 'react';
+import { FeedEntry, FilterState, Feed, Category } from '../../types';
+import { formatDateOnly, stripHtml } from '../../utils';
+import {
+ CheckCheck,
+ LayoutList,
+ AlignJustify,
+ Inbox,
+ Star,
+ Share2,
+ Sparkles,
+} from 'lucide-react';
+
+interface DesktopArticleListProps {
+ entries: FeedEntry[];
+ selectedEntryId: string | null;
+ onSelectEntry: (entry: FeedEntry) => void;
+ width: number;
+ listMode: 'compact' | 'rich';
+ onToggleListMode: (mode: 'compact' | 'rich') => void;
+ onMarkAllRead: () => void;
+ filter: FilterState;
+ onToggleStar: (entry: FeedEntry, e: React.MouseEvent) => void;
+ feeds?: Feed[];
+ categories?: Category[];
+ onFilterChange?: (newFilter: Partial
) => void;
+}
+
+// Extract keywords or tags from entry variants or metadata
+const extractEntryList = (entry: FeedEntry, variantKey: string, metaKey: string): string[] => {
+ const v = entry.variants?.[variantKey]?.text_value;
+ if (v) {
+ return v.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
+ }
+ const val = entry.metadata?.[metaKey];
+ if (Array.isArray(val)) return val.map(String);
+ if (typeof val === 'string' && val) {
+ return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
+ }
+ return [];
+};
+
+export const DesktopArticleList: React.FC = ({
+ entries,
+ selectedEntryId,
+ onSelectEntry,
+ width,
+ listMode,
+ onToggleListMode,
+ onMarkAllRead,
+ filter,
+ onToggleStar,
+ feeds = [],
+ categories = [],
+ onFilterChange,
+}) => {
+ const getFilterTitle = () => {
+ if (filter.search) return `Search: "${filter.search}"`;
+ if (filter.feedId) {
+ const activeFeed = feeds.find((f) => f.id === filter.feedId);
+ if (activeFeed) return activeFeed.title;
+ }
+ if (filter.categoryId) {
+ const activeCat = categories.find((c) => c.id === filter.categoryId);
+ if (activeCat) return activeCat.title;
+ }
+ if (filter.view === 'starred') return 'Starred Articles';
+ if (filter.view === 'all') return 'All Articles';
+ if (filter.view === 'shared') return 'Shared Articles';
+ return 'Inbox';
+ };
+
+ const unreadCount = entries.filter((e) => !e.is_read).length;
+
+ return (
+
+ {/* Header Bar */}
+
+
+
+ {getFilterTitle()}
+
+ {unreadCount > 0 && {unreadCount}}
+
+
+
+ {/* Unread / All Scope Switcher when browsing a specific feed/category */}
+ {(filter.feedId || filter.categoryId) && onFilterChange && (
+
+ onFilterChange({ view: 'inbox' })}
+ title="Show unread articles"
+ >
+ Unread
+
+ onFilterChange({ view: 'all' })}
+ title="Show all articles"
+ >
+ All
+
+
+ )}
+
+ {unreadCount > 0 && (
+
+
+
+ )}
+
+
+
onToggleListMode('compact')}
+ title="Compact 2-line list"
+ >
+
+
+
onToggleListMode('rich')}
+ title="Expanded detailed list"
+ >
+
+
+
+
+
+
+ {/* Scrollable Articles Column */}
+
+ {entries.length === 0 ? (
+
+
+
+ {filter.view === 'inbox' && (filter.feedId || filter.categoryId)
+ ? 'No unread articles in this feed'
+ : 'No articles found'}
+
+ {filter.view === 'inbox' && (filter.feedId || filter.categoryId) && onFilterChange && (
+
onFilterChange({ view: 'all' })}
+ >
+ Switch to All Articles
+
+ )}
+
+ ) : (
+ entries.map((entry) => {
+ const isSelected = selectedEntryId === entry.id;
+ const hasAi = Boolean(entry.variants?.['ai_summary']?.text_value);
+ const isShared = Boolean(entry.share_uuid);
+
+ // 1. Compact 2-Line Mode
+ if (listMode === 'compact') {
+ return (
+
onSelectEntry(entry)}
+ >
+
+
+
{entry.title}
+
{formatDateOnly(entry.published_at)}
+
+
+
+ {isShared && (
+
+
+
+ )}
+ {entry.is_starred && (
+ onToggleStar(entry, e)}
+ title="Starred bookmark (Click to unstar)"
+ >
+
+
+ )}
+
+
+ );
+ }
+
+ // 2. Non-Compact (Expanded) Mode: Title, Description, Date + (Starred, Shared, AI), Keywords + Tags
+ const keywords = extractEntryList(entry, 'keywords', 'keywords');
+ const tags = extractEntryList(entry, 'tags', 'tags');
+ const hasChips = keywords.length > 0 || tags.length > 0;
+
+ return (
+
onSelectEntry(entry)}
+ >
+ {/* Line 1: Title */}
+
{entry.title}
+
+ {/* Line 2: Description */}
+
+ {stripHtml(entry.content || entry.raw_content, 140)}
+
+
+ {/* Line 3: Date (Left) & Starred, Shared, AI Summarized (Right Aligned) */}
+
+
{formatDateOnly(entry.published_at)}
+
+
+ {/* Starred */}
+ {entry.is_starred && (
+ onToggleStar(entry, e)}
+ title="Starred bookmark (Click to unstar)"
+ >
+
+
+ )}
+
+ {/* Shared */}
+ {isShared && (
+
+
+
+ )}
+
+ {/* AI Summarized */}
+ {hasAi && (
+
+
+
+ )}
+
+
+
+ {/* Line 4: Keywords & Tags (in different colors) */}
+ {hasChips && (
+
+ {keywords.map((kw, i) => (
+
+ {kw}
+
+ ))}
+ {tags.map((tg, i) => (
+
+ #{tg}
+
+ ))}
+
+ )}
+
+ );
+ })
+ )}
+
+
+ );
+};
diff --git a/web/src/components/desktop/DesktopReader.tsx b/web/src/components/desktop/DesktopReader.tsx
new file mode 100644
index 0000000..0cf43de
--- /dev/null
+++ b/web/src/components/desktop/DesktopReader.tsx
@@ -0,0 +1,554 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { FeedEntry } from '../../types';
+import { api } from '../../services/api';
+import { formatDateTime, estimateReadingTime, renderMarkdownText } from '../../utils';
+import {
+ Star,
+ ExternalLink,
+ Copy,
+ Zap,
+ Loader2,
+ Sparkles,
+ FileText,
+ Clock,
+ User as UserIcon,
+ CheckCircle2,
+ Share2,
+ ChevronDown,
+ Headphones,
+ Play,
+ Pause,
+ AlertCircle,
+ X,
+} from 'lucide-react';
+
+interface DesktopReaderProps {
+ entry: FeedEntry | null;
+ onToggleStar: (entry: FeedEntry) => void;
+ onToggleRead: (entry: FeedEntry) => void;
+ onUpdateEntry?: (entry: FeedEntry) => void;
+ onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
+ currentAudioUrl?: string | null;
+ isPlayingAudio?: boolean;
+}
+
+export const DesktopReader: React.FC = ({
+ entry,
+ onToggleStar,
+ onToggleRead,
+ onUpdateEntry,
+ onPlayAudio,
+ currentAudioUrl,
+ isPlayingAudio,
+}) => {
+ const [selectedVariantKey, setSelectedVariantKey] = useState('original');
+ const [isScraping, setIsScraping] = useState(false);
+ const [isGeneratingAI, setIsGeneratingAI] = useState(false);
+ const [aiError, setAiError] = useState(null);
+ const [isSharing, setIsSharing] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [shareCopied, setShareCopied] = useState(false);
+ const [showActionDropdown, setShowActionDropdown] = useState(false);
+
+ const dropdownRef = useRef(null);
+ const scrollAreaRef = useRef(null);
+ const autoReadTimerRef = useRef | null>(null);
+
+ // Helper to mark current entry as read if unread
+ const markAsReadIfUnread = () => {
+ if (entry && !entry.is_read) {
+ onToggleRead(entry);
+ }
+ };
+
+ // Automatic read triggers
+ useEffect(() => {
+ if (autoReadTimerRef.current) {
+ clearTimeout(autoReadTimerRef.current);
+ autoReadTimerRef.current = null;
+ }
+
+ if (!entry || entry.is_read) return;
+
+ const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
+ if (!isAutoReadEnabled) return;
+
+ // 1. If content <= 1 page on view, auto mark as read after 3 seconds
+ const measureTimer = setTimeout(() => {
+ const scrollEl = scrollAreaRef.current;
+ if (!scrollEl) return;
+
+ const isShortContent = scrollEl.scrollHeight <= scrollEl.clientHeight + 25;
+ if (isShortContent) {
+ autoReadTimerRef.current = setTimeout(() => {
+ markAsReadIfUnread();
+ }, 3000);
+ }
+ }, 150);
+
+ return () => {
+ clearTimeout(measureTimer);
+ if (autoReadTimerRef.current) {
+ clearTimeout(autoReadTimerRef.current);
+ autoReadTimerRef.current = null;
+ }
+ };
+ }, [entry?.id, entry?.is_read, selectedVariantKey]);
+
+ // 2. If click to the content view (only content, not including the right pane toolbar)
+ const handleContentClick = () => {
+ const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
+ if (!isAutoReadEnabled || !entry || entry.is_read) return;
+ markAsReadIfUnread();
+ };
+
+ // 3. If content > 1 page on view, scroll on the right pane will auto mark as read
+ const handleContentScroll = () => {
+ const isAutoReadEnabled = localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
+ if (!isAutoReadEnabled || !entry || entry.is_read) return;
+
+ const scrollEl = scrollAreaRef.current;
+ if (!scrollEl) return;
+
+ const isLongContent = scrollEl.scrollHeight > scrollEl.clientHeight + 25;
+ if (isLongContent && scrollEl.scrollTop > 15) {
+ markAsReadIfUnread();
+ }
+ };
+
+ // Sync selected variant and clear transient errors when switching articles
+ useEffect(() => {
+ setAiError(null);
+ if (entry?.variants?.['readability_full_text']) {
+ setSelectedVariantKey('readability_full_text');
+ } else {
+ setSelectedVariantKey('original');
+ }
+ }, [entry?.id]);
+
+ // Close dropdown on click outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setShowActionDropdown(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ if (!entry) {
+ return (
+
+
+
+
+ No Article Selected
+
+
+ Choose an article from the list or press j / k to navigate
+
+
+
+ );
+ }
+
+ const variants = entry.variants || {};
+ const hasReadability = Boolean(variants['readability_full_text']?.text_value);
+ const hasAISummary = Boolean(variants['ai_summary']?.text_value);
+ const ttsVariant = variants['tts'] || variants['podcast_tts'];
+ const isShared = Boolean(entry.share_uuid);
+
+ // Extract keywords / tags from variants or metadata
+ const extractList = (variantKey: string, metaKey: string): string[] => {
+ if (variants[variantKey]?.text_value) {
+ return variants[variantKey].text_value
+ .split(/[,,\n]/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ }
+ const val = entry.metadata?.[metaKey];
+ if (Array.isArray(val)) return val.map(String);
+ if (typeof val === 'string' && val) {
+ return val.split(/[,,\n]/).map((s) => s.trim()).filter(Boolean);
+ }
+ return [];
+ };
+
+ const keywords = extractList('keywords', 'keywords');
+ const tags = extractList('tags', 'tags');
+
+ // Handle Scrape Full Text
+ const handleScrapeFullText = async () => {
+ if (!entry || isScraping) return;
+ setIsScraping(true);
+ try {
+ const updated = await api.scrapeEntry(entry.id);
+ onUpdateEntry?.(updated);
+ setSelectedVariantKey('readability_full_text');
+ } catch (err: any) {
+ setAiError(`Full text extraction failed: ${err.message || err}`);
+ } finally {
+ setIsScraping(false);
+ }
+ };
+
+ // Handle Primary Split Action (Toggle between Full Text and Original)
+ const handlePrimaryActionClick = () => {
+ if (!hasReadability) {
+ handleScrapeFullText();
+ } else if (selectedVariantKey === 'readability_full_text') {
+ setSelectedVariantKey('original');
+ } else {
+ setSelectedVariantKey('readability_full_text');
+ }
+ };
+
+ // Handle On-Demand AI Summary Generation
+ const handleGenerateAISummary = async () => {
+ setShowActionDropdown(false);
+ if (!entry || isGeneratingAI) return;
+ setIsGeneratingAI(true);
+ setAiError(null);
+ try {
+ const updated = await api.generateAISummary(entry.id);
+ onUpdateEntry?.(updated);
+ } catch (err: any) {
+ setAiError(err.message || 'AI generation failed. Please check AI settings.');
+ } finally {
+ setIsGeneratingAI(false);
+ }
+ };
+
+ // Handle Toggle Public Share
+ const handleToggleShare = async () => {
+ if (!entry || isSharing) return;
+ setIsSharing(true);
+ try {
+ const res = await api.toggleShare(entry.id);
+ onUpdateEntry?.(res.entry);
+ if (res.shared && res.share_uuid) {
+ const shareUrl = `${window.location.origin}/share/${res.share_uuid}`;
+ navigator.clipboard.writeText(shareUrl);
+ setShareCopied(true);
+ setTimeout(() => setShareCopied(false), 2500);
+ }
+ } catch (err: any) {
+ setAiError(`Sharing failed: ${err.message || err}`);
+ } finally {
+ setIsSharing(false);
+ }
+ };
+
+ const handleCopyLink = () => {
+ if (!entry.url) return;
+ navigator.clipboard.writeText(entry.url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const getRenderedContent = () => {
+ if (selectedVariantKey === 'readability_full_text' && variants['readability_full_text']?.text_value) {
+ return variants['readability_full_text'].text_value;
+ }
+ return entry.content || entry.raw_content || '';
+ };
+
+ const isCurrentAudioActive = Boolean(
+ ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
+ );
+
+ return (
+
+ {/* Top Action Toolbar */}
+
+ {/* Left Toolbar Actions */}
+
+ {/* 1. Toggle Read/Unread */}
+
onToggleRead(entry)}
+ title={entry.is_read ? 'Mark as unread (m)' : 'Mark as read (m)'}
+ >
+
+
+
+ {/* 2. Toggle Star */}
+
onToggleStar(entry)}
+ title={entry.is_starred ? 'Unstar bookmark (s)' : 'Star bookmark (s)'}
+ >
+
+
+
+ {/* 3. Toggle Shared */}
+
+ {isSharing ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 4. Copy Link to Pasteboard */}
+
+
+
+
+ {/* 5. Go to Original Link */}
+
+
+
+
+
+ {/* Right Toolbar: Split Action Button & Actions Dropdown */}
+
+ {shareCopied && (
+
+ ✓ Share link copied!
+
+ )}
+
+
+ {/* Primary Action Button */}
+
+ {isScraping ? (
+ <>
+
+ Scraping...
+ >
+ ) : !hasReadability ? (
+ <>
+
+ Get Full Text
+ >
+ ) : selectedVariantKey === 'readability_full_text' ? (
+ <>
+
+ Original Feed
+ >
+ ) : (
+ <>
+
+ Full Text (Clean)
+ >
+ )}
+
+
+ {/* Split Dropdown Trigger */}
+
setShowActionDropdown(!showActionDropdown)}
+ title="More actions"
+ >
+
+
+
+ {/* Dropdown Menu (Only AI Summary and Audio Generation) */}
+ {showActionDropdown && (
+
+ {/* 1. Generate AI Summary */}
+
+ {isGeneratingAI ? (
+
+ ) : (
+
+ )}
+ {isGeneratingAI ? 'Generating AI Summary...' : 'Generate AI Summary'}
+
+
+ {/* 2. Generate Audio (Placeholder) */}
+ {
+ setShowActionDropdown(false);
+ alert('Audio narration synthesis is in development and will be available soon!');
+ }}
+ >
+
+ Generate Audio
+
+
+ )}
+
+
+
+
+ {/* Reader Scrollable Container (Full width, scrollbar tracks on the far right) */}
+
+
+ {/* Header Section */}
+
+ {/* 1. Secondary Title: Feed Title */}
+
+ {entry.feed_site_url && (
+

(e.currentTarget.style.display = 'none')}
+ />
+ )}
+
{entry.feed_title}
+
+
+ {/* 2. Post Title */}
+
{entry.title}
+
+ {/* 3. Author (if exists) */}
+ {entry.author && (
+
+
+ {entry.author}
+
+ )}
+
+ {/* 4. Updated Date (YYYY-MM-DD HH:mm) & Time to Read */}
+
+
+ {formatDateTime(entry.published_at)}
+ •
+ {estimateReadingTime(entry.content || entry.raw_content)}
+
+
+ {/* 5. Keywords (if exists) */}
+ {keywords.length > 0 && (
+
+
Keywords:
+
+ {keywords.map((kw, i) => (
+
+ {kw}
+
+ ))}
+
+
+ )}
+
+ {/* 6. Tags (if exists) */}
+ {tags.length > 0 && (
+
+
Tags:
+
+ {tags.map((tg, i) => (
+
+ #{tg}
+
+ ))}
+
+
+ )}
+
+
+ {/* 7. Splitter Divider */}
+
+
+ {/* Transient AI Error Message Block (In-page error on summary area) */}
+ {aiError && (
+
+
+
+
setAiError(null)}
+ style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--text-muted)' }}
+ title="Dismiss error"
+ >
+
+
+
+
+ {aiError}
+
+
+ )}
+
+ {/* 8. AI Executive Summary (if exists) */}
+ {hasAISummary && (
+
+
+
+ AI Executive Summary
+
+
+
+ )}
+
+ {/* 9. Audio Narration Bar (if exists) */}
+ {ttsVariant?.stream_url && (
+
+
onPlayAudio?.(entry, ttsVariant.stream_url!)}
+ title="Play Narration Audio"
+ >
+ {isCurrentAudioActive ? : }
+
+
+
+ {ttsVariant.variant_key === 'podcast_tts' ? 'Podcast Audio Episode' : 'Voice Narration'}
+
+
+ Listen to synthesized audio for this article
+
+
+
+ )}
+
+ {/* 10. Article Content Body */}
+
+
+
+
+ );
+};
diff --git a/web/src/components/desktop/DesktopSidebar.tsx b/web/src/components/desktop/DesktopSidebar.tsx
new file mode 100644
index 0000000..1c7b753
--- /dev/null
+++ b/web/src/components/desktop/DesktopSidebar.tsx
@@ -0,0 +1,261 @@
+import React, { useState } from 'react';
+import { Category, Feed, FilterState, Stats } from '../../types';
+import {
+ Inbox,
+ Star,
+ Layers,
+ Share2,
+ ChevronDown,
+ ChevronRight,
+ Folder,
+ Rss,
+ Plus,
+} from 'lucide-react';
+
+interface DesktopSidebarProps {
+ filter: FilterState;
+ onFilterChange: (newFilter: Partial) => void;
+ categories: Category[];
+ feeds: Feed[];
+ stats: Stats | null;
+ width: number;
+ onOpenSettings: () => void;
+ onOpenAddFeed: () => void;
+}
+
+export const FeedIcon: React.FC<{ feed: Feed; size?: number }> = ({ feed, size = 15 }) => {
+ const [imgError, setImgError] = useState(false);
+ const iconUrl =
+ feed.icon_url ||
+ (feed.site_url
+ ? `https://www.google.com/s2/favicons?domain=${feed.site_url.replace(/^https?:\/\//, '').split('/')[0]}&sz=64`
+ : '');
+
+ if (iconUrl && !imgError) {
+ return (
+
setImgError(true)}
+ loading="lazy"
+ />
+ );
+ }
+ return ;
+};
+
+export const DesktopSidebar: React.FC = ({
+ filter,
+ onFilterChange,
+ categories,
+ feeds,
+ stats,
+ width,
+ onOpenAddFeed,
+}) => {
+ const [collapsedCategories, setCollapsedCategories] = useState>({});
+
+ const toggleCategory = (catId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setCollapsedCategories((prev) => ({
+ ...prev,
+ [catId]: !prev[catId],
+ }));
+ };
+
+ const feedsByCategory = categories.map((cat) => ({
+ category: cat,
+ feeds: feeds.filter((f) => f.category_id === cat.id),
+ }));
+
+ const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
+
+ // Handle feed selection with safe view resolution & search reset
+ const handleSelectFeed = (feed: Feed) => {
+ onFilterChange({
+ feedId: feed.id,
+ categoryId: undefined,
+ search: '',
+ view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
+ });
+ };
+
+ // Handle category selection with safe view resolution & search reset
+ const handleSelectCategory = (category: Category) => {
+ onFilterChange({
+ categoryId: category.id,
+ feedId: undefined,
+ search: '',
+ view: filter.view === 'starred' || filter.view === 'shared' ? 'all' : filter.view,
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/web/src/components/desktop/ResizableSplitter.tsx b/web/src/components/desktop/ResizableSplitter.tsx
new file mode 100644
index 0000000..7b31bc2
--- /dev/null
+++ b/web/src/components/desktop/ResizableSplitter.tsx
@@ -0,0 +1,64 @@
+import React, { useState, useEffect, useCallback } from 'react';
+
+interface ResizableSplitterProps {
+ onResize: (deltaX: number) => void;
+ onDoubleClick?: () => void;
+ title?: string;
+}
+
+export const ResizableSplitter: React.FC = ({
+ onResize,
+ onDoubleClick,
+ title = 'Drag to resize columns',
+}) => {
+ const [isDragging, setIsDragging] = useState(false);
+ const [startX, setStartX] = useState(0);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ setStartX(e.clientX);
+ };
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (!isDragging) return;
+ const deltaX = e.clientX - startX;
+ if (deltaX !== 0) {
+ onResize(deltaX);
+ setStartX(e.clientX);
+ }
+ },
+ [isDragging, startX, onResize]
+ );
+
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ useEffect(() => {
+ if (isDragging) {
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ } else {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ }
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging, handleMouseMove, handleMouseUp]);
+
+ return (
+ <>
+
+ {isDragging && }
+ >
+ );
+};
diff --git a/web/src/components/desktop/ThreePaneView.tsx b/web/src/components/desktop/ThreePaneView.tsx
new file mode 100644
index 0000000..790a535
--- /dev/null
+++ b/web/src/components/desktop/ThreePaneView.tsx
@@ -0,0 +1,139 @@
+import React, { useState, useEffect } from 'react';
+import { Category, Feed, FeedEntry, FilterState, Stats } from '../../types';
+import { DesktopSidebar } from './DesktopSidebar';
+import { DesktopArticleList } from './DesktopArticleList';
+import { DesktopReader } from './DesktopReader';
+import { ResizableSplitter } from './ResizableSplitter';
+
+interface ThreePaneViewProps {
+ filter: FilterState;
+ onFilterChange: (newFilter: Partial) => void;
+ categories: Category[];
+ feeds: Feed[];
+ entries: FeedEntry[];
+ selectedEntryId: string | null;
+ onSelectEntry: (entry: FeedEntry) => void;
+ onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
+ onToggleRead: (entry: FeedEntry) => void;
+ onMarkAllRead: () => void;
+ onUpdateEntry?: (entry: FeedEntry) => void;
+ stats: Stats | null;
+ onOpenSettings: () => void;
+ onOpenAddFeed: () => void;
+ onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
+ currentAudioUrl?: string | null;
+ isPlayingAudio?: boolean;
+}
+
+export const ThreePaneView: React.FC = ({
+ filter,
+ onFilterChange,
+ categories,
+ feeds,
+ entries,
+ selectedEntryId,
+ onSelectEntry,
+ onToggleStar,
+ onToggleRead,
+ onMarkAllRead,
+ onUpdateEntry,
+ stats,
+ onOpenSettings,
+ onOpenAddFeed,
+ onPlayAudio,
+ currentAudioUrl,
+ isPlayingAudio,
+}) => {
+ // Pane Widths with persistence
+ const [sidebarWidth, setSidebarWidth] = useState(() => {
+ const saved = localStorage.getItem('rsspipe_sidebar_width');
+ return saved ? parseInt(saved, 10) : 240;
+ });
+
+ const [listWidth, setListWidth] = useState(() => {
+ const saved = localStorage.getItem('rsspipe_list_width');
+ return saved ? parseInt(saved, 10) : 380;
+ });
+
+ const [listMode, setListMode] = useState<'compact' | 'rich'>(() => {
+ const saved = localStorage.getItem('rsspipe_desktop_list_mode');
+ return saved === 'rich' ? 'rich' : 'compact';
+ });
+
+ useEffect(() => {
+ localStorage.setItem('rsspipe_sidebar_width', sidebarWidth.toString());
+ }, [sidebarWidth]);
+
+ useEffect(() => {
+ localStorage.setItem('rsspipe_list_width', listWidth.toString());
+ }, [listWidth]);
+
+ useEffect(() => {
+ localStorage.setItem('rsspipe_desktop_list_mode', listMode);
+ }, [listMode]);
+
+ const handleResizeSidebar = (deltaX: number) => {
+ setSidebarWidth((prev) => Math.max(180, Math.min(prev + deltaX, 420)));
+ };
+
+ const handleResizeList = (deltaX: number) => {
+ setListWidth((prev) => Math.max(260, Math.min(prev + deltaX, 600)));
+ };
+
+ const selectedEntry = entries.find((e) => e.id === selectedEntryId) || null;
+
+ return (
+
+ {/* Pane 1: Sidebar */}
+
+
+ {/* Splitter 1 */}
+
+
+ {/* Pane 2: Article List */}
+
+
+ {/* Splitter 2 */}
+
+
+ {/* Pane 3: Detail Reader */}
+
+
+ );
+};
diff --git a/web/src/components/mobile/DockedAudioPlayer.tsx b/web/src/components/mobile/DockedAudioPlayer.tsx
new file mode 100644
index 0000000..454041e
--- /dev/null
+++ b/web/src/components/mobile/DockedAudioPlayer.tsx
@@ -0,0 +1,241 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { FeedEntry } from '../../types';
+import {
+ Play,
+ Pause,
+ RotateCcw,
+ RotateCw,
+ X,
+ ChevronDown,
+ Volume2,
+} from 'lucide-react';
+
+interface DockedAudioPlayerProps {
+ entry: FeedEntry | null;
+ streamUrl: string | null;
+ isPlaying: boolean;
+ onTogglePlay: () => void;
+ onClose: () => void;
+}
+
+export const DockedAudioPlayer: React.FC = ({
+ entry,
+ streamUrl,
+ isPlaying,
+ onTogglePlay,
+ onClose,
+}) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [playbackRate, setPlaybackRate] = useState(1);
+ const audioRef = useRef(null);
+
+ useEffect(() => {
+ if (!streamUrl) return;
+ if (!audioRef.current) {
+ audioRef.current = new Audio();
+ }
+ const audio = audioRef.current;
+ audio.src = streamUrl;
+ audio.playbackRate = playbackRate;
+
+ const updateTime = () => setCurrentTime(audio.currentTime);
+ const updateDuration = () => setDuration(audio.duration || 0);
+ const handleEnded = () => onTogglePlay();
+
+ audio.addEventListener('timeupdate', updateTime);
+ audio.addEventListener('loadedmetadata', updateDuration);
+ audio.addEventListener('ended', handleEnded);
+
+ if (isPlaying) {
+ audio.play().catch(() => {});
+ }
+
+ return () => {
+ audio.removeEventListener('timeupdate', updateTime);
+ audio.removeEventListener('loadedmetadata', updateDuration);
+ audio.removeEventListener('ended', handleEnded);
+ audio.pause();
+ };
+ }, [streamUrl]);
+
+ useEffect(() => {
+ if (!audioRef.current) return;
+ if (isPlaying) {
+ audioRef.current.play().catch(() => {});
+ } else {
+ audioRef.current.pause();
+ }
+ }, [isPlaying]);
+
+ useEffect(() => {
+ if (audioRef.current) {
+ audioRef.current.playbackRate = playbackRate;
+ }
+ }, [playbackRate]);
+
+ if (!entry || !streamUrl) return null;
+
+ const handleSeek = (newTime: number) => {
+ if (audioRef.current) {
+ audioRef.current.currentTime = newTime;
+ setCurrentTime(newTime);
+ }
+ };
+
+ const handleSkip = (seconds: number) => {
+ if (audioRef.current) {
+ const nextTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration));
+ audioRef.current.currentTime = nextTime;
+ setCurrentTime(nextTime);
+ }
+ };
+
+ const formatTime = (secs: number) => {
+ if (isNaN(secs)) return '0:00';
+ const m = Math.floor(secs / 60);
+ const s = Math.floor(secs % 60);
+ return `${m}:${s < 10 ? '0' : ''}${s}`;
+ };
+
+ const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
+
+ return (
+ <>
+ {/* Docked Mini Player (Fixed above Bottom Navigation Bar) */}
+ setIsExpanded(true)}>
+
+
+
+
+
e.stopPropagation()}>
+
handleSkip(-15)}
+ title="Rewind 15s"
+ >
+
+
+
+
+ {isPlaying ? : }
+
+
+
handleSkip(15)}
+ title="Forward 15s"
+ >
+
+
+
+
+
+
+
+
+
+ {/* Expanded Full Player Sheet */}
+ {isExpanded && (
+ setIsExpanded(false)}>
+
e.stopPropagation()}>
+
+
setIsExpanded(false)}
+ >
+
+
+
+ Now Playing
+
+
+
+
+
+
+ {entry.title}
+
+
+ {entry.feed_title}
+
+
+
+ {/* Scrubber */}
+
+
handleSeek(parseFloat(e.target.value))}
+ className="audio-scrubber-slider"
+ />
+
+ {formatTime(currentTime)}
+ {formatTime(duration)}
+
+
+
+ {/* Play Controls */}
+
+
handleSkip(-15)}
+ >
+
+
+
+
+ {isPlaying ? : }
+
+
+
handleSkip(15)}
+ >
+
+
+
+
+ {/* Speed Selector */}
+
+ {[0.75, 1.0, 1.25, 1.5, 2.0].map((rate) => (
+ setPlaybackRate(rate)}
+ >
+ {rate}x
+
+ ))}
+
+
+
+ )}
+ >
+ );
+};
diff --git a/web/src/components/mobile/FeedsTabView.tsx b/web/src/components/mobile/FeedsTabView.tsx
new file mode 100644
index 0000000..0b0d2ab
--- /dev/null
+++ b/web/src/components/mobile/FeedsTabView.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Category, Feed } from '../../types';
+import { Rss, Folder, Plus, ChevronRight } from 'lucide-react';
+
+interface FeedsTabViewProps {
+ categories: Category[];
+ feeds: Feed[];
+ onSelectFeed: (feed: Feed) => void;
+ onOpenAddFeed: () => void;
+}
+
+export const FeedsTabView: React.FC = ({
+ categories,
+ feeds,
+ onSelectFeed,
+ onOpenAddFeed,
+}) => {
+ const feedsByCategory = categories.map((cat) => ({
+ category: cat,
+ feeds: feeds.filter((f) => f.category_id === cat.id),
+ }));
+
+ const uncategorizedFeeds = feeds.filter((f) => !f.category_id);
+
+ return (
+
+
+
Subscriptions
+
+
+ Add Feed
+
+
+
+
+ {feedsByCategory.map(({ category, feeds: catFeeds }) => {
+ if (catFeeds.length === 0) return null;
+ return (
+
+
+
+ {category.title}
+
+
+
+ {catFeeds.map((feed) => (
+
onSelectFeed(feed)}
+ >
+
+
+ {feed.title}
+
+
+ {feed.unread_count > 0 && (
+ {feed.unread_count}
+ )}
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ {uncategorizedFeeds.length > 0 && (
+
+
+
+ Uncategorized
+
+
+
+ {uncategorizedFeeds.map((feed) => (
+
onSelectFeed(feed)}
+ >
+
+
+ {feed.title}
+
+
+ {feed.unread_count > 0 && (
+ {feed.unread_count}
+ )}
+
+
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/web/src/components/mobile/MobileArticleSheet.tsx b/web/src/components/mobile/MobileArticleSheet.tsx
new file mode 100644
index 0000000..3bcb1ff
--- /dev/null
+++ b/web/src/components/mobile/MobileArticleSheet.tsx
@@ -0,0 +1,215 @@
+import React, { useState, useRef } from 'react';
+import { FeedEntry } from '../../types';
+import { formatRelativeDate, estimateReadingTime, renderMarkdownText } from '../../utils';
+import {
+ ChevronLeft,
+ Star,
+ ExternalLink,
+ Share2,
+ Sparkles,
+ Volume2,
+ Pause,
+} from 'lucide-react';
+
+interface MobileArticleSheetProps {
+ entry: FeedEntry;
+ onClose: () => void;
+ onToggleStar: (entry: FeedEntry) => void;
+ onToggleRead?: (entry: FeedEntry) => void;
+ onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
+ currentAudioUrl?: string | null;
+ isPlayingAudio?: boolean;
+}
+
+export const MobileArticleSheet: React.FC = ({
+ entry,
+ onClose,
+ onToggleStar,
+ onPlayAudio,
+ currentAudioUrl,
+ isPlayingAudio,
+}) => {
+ const [touchStartX, setTouchStartX] = useState(null);
+ const [touchDeltaX, setTouchDeltaX] = useState(0);
+ const [isFromLeftEdge, setIsFromLeftEdge] = useState(false);
+ const sheetRef = useRef(null);
+
+ // Left-edge swipe to dismiss gesture handlers
+ const handleTouchStart = (e: React.TouchEvent) => {
+ const startX = e.touches[0].clientX;
+ // Only activate if swipe originates from within 45px of the left edge
+ if (startX <= 45) {
+ setIsFromLeftEdge(true);
+ setTouchStartX(startX);
+ setTouchDeltaX(0);
+ } else {
+ setIsFromLeftEdge(false);
+ setTouchStartX(null);
+ }
+ };
+
+ const handleTouchMove = (e: React.TouchEvent) => {
+ if (!isFromLeftEdge || touchStartX === null) return;
+ const currentX = e.touches[0].clientX;
+ const delta = currentX - touchStartX;
+ if (delta > 0) {
+ setTouchDeltaX(delta);
+ }
+ };
+
+ const handleTouchEnd = () => {
+ if (isFromLeftEdge && touchDeltaX > 90) {
+ onClose();
+ } else {
+ setTouchDeltaX(0);
+ }
+ setIsFromLeftEdge(false);
+ setTouchStartX(null);
+ };
+
+ const handleShare = async () => {
+ if (navigator.share && entry.url) {
+ try {
+ await navigator.share({
+ title: entry.title,
+ url: entry.url,
+ });
+ } catch {
+ // ignore share cancellation
+ }
+ } else if (entry.url) {
+ navigator.clipboard.writeText(entry.url);
+ alert('Link copied to clipboard!');
+ }
+ };
+
+ const variants = entry.variants || {};
+ const hasAISummary = Boolean(variants['ai_summary']);
+ const ttsVariant = variants['tts'] || variants['podcast_tts'];
+
+ const isCurrentAudioActive = Boolean(
+ ttsVariant?.stream_url && currentAudioUrl === ttsVariant.stream_url && isPlayingAudio
+ );
+
+ const getArticleBody = () => {
+ if (variants['readability_full_text']?.text_value) {
+ return variants['readability_full_text'].text_value;
+ }
+ return entry.content || entry.raw_content || '';
+ };
+
+ return (
+ 0 ? `translateX(${touchDeltaX}px)` : undefined,
+ transition: touchDeltaX > 0 ? 'none' : undefined,
+ }}
+ onTouchStart={handleTouchStart}
+ onTouchMove={handleTouchMove}
+ onTouchEnd={handleTouchEnd}
+ >
+ {/* Top Header */}
+
+
+
+
+
+
+ {ttsVariant?.stream_url && (
+
onPlayAudio?.(entry, ttsVariant.stream_url!)}
+ title="Play Audio"
+ >
+ {isCurrentAudioActive ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
onToggleStar(entry)}
+ title="Star article"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Scrollable Article Body */}
+
+
+
+ {entry.title}
+
+
+
+ {entry.feed_title}
+ •
+ {formatRelativeDate(entry.published_at)}
+ •
+ {estimateReadingTime(entry.content || entry.raw_content)}
+
+
+
+ {/* AI Summary Box */}
+ {hasAISummary && (
+
+ )}
+
+ {/* Rendered HTML/Markdown Content */}
+
+
+
+ );
+};
diff --git a/web/src/components/mobile/SmartArtCard.tsx b/web/src/components/mobile/SmartArtCard.tsx
new file mode 100644
index 0000000..8b4f56d
--- /dev/null
+++ b/web/src/components/mobile/SmartArtCard.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { getSmartArtTheme, stripHtml } from '../../utils';
+
+interface SmartArtCardProps {
+ title: string;
+ seed: string;
+ excerpt?: string;
+}
+
+export const SmartArtCard: React.FC = ({ title, seed, excerpt }) => {
+ const theme = getSmartArtTheme(seed);
+ const textToShow = excerpt ? stripHtml(excerpt, 90) : title;
+
+ return (
+
+ );
+};
diff --git a/web/src/components/mobile/StarredTabView.tsx b/web/src/components/mobile/StarredTabView.tsx
new file mode 100644
index 0000000..fe00c93
--- /dev/null
+++ b/web/src/components/mobile/StarredTabView.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { Feed, FeedEntry } from '../../types';
+import { Star, ChevronRight } from 'lucide-react';
+
+interface StarredTabViewProps {
+ entries: FeedEntry[];
+ feeds: Feed[];
+ onSelectFeedFilter: (feedId?: string) => void;
+ onOpenAllStarred: () => void;
+}
+
+export const StarredTabView: React.FC = ({
+ entries,
+ feeds,
+ onSelectFeedFilter,
+ onOpenAllStarred,
+}) => {
+ const starredEntries = entries.filter((e) => e.is_starred);
+
+ // Group starred count by feed
+ const starredCountsByFeed: Record = {};
+ starredEntries.forEach((e) => {
+ starredCountsByFeed[e.feed_id] = (starredCountsByFeed[e.feed_id] || 0) + 1;
+ });
+
+ const feedsWithStarred = feeds.filter((f) => starredCountsByFeed[f.id] > 0);
+
+ return (
+
+
+
Starred Items
+
+ {starredEntries.length} saved articles across {feedsWithStarred.length} sources
+
+
+
+
+
+
+
+
+ All Starred Articles
+
+
+
+ {starredEntries.length}
+
+
+
+
+ {feedsWithStarred.map((feed) => (
+
onSelectFeedFilter(feed.id)}
+ >
+
+ {feed.title}
+
+
+ {starredCountsByFeed[feed.id]}
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/web/src/components/mobile/WaterfallView.tsx b/web/src/components/mobile/WaterfallView.tsx
new file mode 100644
index 0000000..b691837
--- /dev/null
+++ b/web/src/components/mobile/WaterfallView.tsx
@@ -0,0 +1,344 @@
+import React, { useState } from 'react';
+import { Category, Feed, FeedEntry, FilterState, Stats, User, ViewPreference } from '../../types';
+import { extractFirstImage, formatRelativeDate } from '../../utils';
+import { XHSBottomNav, MobileTab } from './XHSBottomNav';
+import { SmartArtCard } from './SmartArtCard';
+import { MobileArticleSheet } from './MobileArticleSheet';
+import { DockedAudioPlayer } from './DockedAudioPlayer';
+import { FeedsTabView } from './FeedsTabView';
+import { StarredTabView } from './StarredTabView';
+import { SettingsView } from '../settings/SettingsView';
+import {
+ Search,
+ Headphones,
+ Sparkles,
+ ArrowLeft,
+ X,
+ Columns3,
+} from 'lucide-react';
+
+interface WaterfallViewProps {
+ filter: FilterState;
+ onFilterChange: (newFilter: Partial) => void;
+ categories: Category[];
+ feeds: Feed[];
+ entries: FeedEntry[];
+ stats: Stats | null;
+ currentUser: User;
+ onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
+ onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
+ onUpdateEntry?: (entry: FeedEntry) => void;
+ onRefreshData: () => void;
+ onOpenAddFeed: () => void;
+ onPlayAudio?: (entry: FeedEntry, streamUrl: string) => void;
+ currentAudioUrl?: string | null;
+ isPlayingAudio?: boolean;
+ onTogglePlayAudio?: () => void;
+ onCloseAudio?: () => void;
+ playingEntry?: FeedEntry | null;
+ viewPreference: ViewPreference;
+ onChangeViewPreference: (pref: ViewPreference) => void;
+ onSwitchToDesktop?: () => void;
+}
+
+export const WaterfallView: React.FC = ({
+ filter,
+ onFilterChange,
+ categories,
+ feeds,
+ entries,
+ stats,
+ currentUser,
+ onToggleRead,
+ onToggleStar,
+ onRefreshData,
+ onOpenAddFeed,
+ onPlayAudio,
+ currentAudioUrl,
+ isPlayingAudio,
+ onTogglePlayAudio,
+ onCloseAudio,
+ playingEntry,
+ viewPreference,
+ onChangeViewPreference,
+ onSwitchToDesktop,
+}) => {
+ const [mobileTab, setMobileTab] = useState('home');
+ const [selectedEntry, setSelectedEntry] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [showSearchInput, setShowSearchInput] = useState(false);
+
+ const handleCardClick = (entry: FeedEntry) => {
+ setSelectedEntry(entry);
+ if (!entry.is_read) {
+ onToggleRead(entry);
+ }
+ };
+
+ const handleSearchSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onFilterChange({ search: searchQuery.trim() });
+ };
+
+ const clearSearch = () => {
+ setSearchQuery('');
+ setShowSearchInput(false);
+ onFilterChange({ search: '' });
+ };
+
+ // Filter entries based on active tab and search
+ let displayedEntries = entries;
+ if (mobileTab === 'starred') {
+ displayedEntries = entries.filter((e) => e.is_starred);
+ } else if (mobileTab === 'listen') {
+ displayedEntries = entries.filter(
+ (e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
+ );
+ }
+
+ // Split into 2 columns for Masonry layout
+ const col1: FeedEntry[] = [];
+ const col2: FeedEntry[] = [];
+ displayedEntries.forEach((entry, idx) => {
+ if (idx % 2 === 0) col1.push(entry);
+ else col2.push(entry);
+ });
+
+ const playableCount = entries.filter(
+ (e) => e.variants?.['tts'] || e.variants?.['podcast_tts']
+ ).length;
+
+ const currentFeedFilter = feeds.find((f) => f.id === filter.feedId);
+
+ return (
+
+ {/* Mobile Top Header */}
+
+
+ {/* Top Category Pill Tabs (In Home View) */}
+ {mobileTab === 'home' && !filter.feedId && (
+
+ onFilterChange({ categoryId: undefined, view: 'inbox' })}
+ >
+ All
+
+ {categories.map((cat) => (
+ onFilterChange({ categoryId: cat.id, view: 'all' })}
+ >
+ {cat.title}
+
+ ))}
+
+ )}
+
+ {/* Main Tab Content */}
+ {mobileTab === 'feeds' ? (
+
{
+ onFilterChange({ feedId: feed.id, view: 'all' });
+ setMobileTab('home');
+ }}
+ onOpenAddFeed={onOpenAddFeed}
+ />
+ ) : mobileTab === 'starred' ? (
+ e.is_starred)}
+ feeds={feeds}
+ onSelectFeedFilter={(feedId) => {
+ onFilterChange({ feedId, view: 'starred' });
+ setMobileTab('home');
+ }}
+ onOpenAllStarred={() => {
+ onFilterChange({ view: 'starred', feedId: undefined });
+ setMobileTab('home');
+ }}
+ />
+ ) : mobileTab === 'settings' ? (
+
+ setMobileTab('home')}
+ viewPreference={viewPreference}
+ onChangeViewPreference={onChangeViewPreference}
+ />
+
+ ) : (
+ /* Waterfall 2-Column Grid (Home / Listen / Filtered Feeds) */
+
+ {displayedEntries.length === 0 ? (
+
+
+ No posts found
+
+
+ Pull to refresh or check other categories
+
+
+ ) : (
+
+ {/* Column 1 */}
+
+ {col1.map((entry) => renderCard(entry))}
+
+ {/* Column 2 */}
+
+ {col2.map((entry) => renderCard(entry))}
+
+
+ )}
+
+ )}
+
+ {/* Docked Mini Audio Player (when audio is active) */}
+ {currentAudioUrl && (
+ onTogglePlayAudio?.()}
+ onClose={() => onCloseAudio?.()}
+ />
+ )}
+
+ {/* iOS 5-Tab Bottom Navigation Bar */}
+ {
+ setMobileTab(tab);
+ if (tab === 'starred') {
+ onFilterChange({ view: 'starred' });
+ } else if (tab === 'home') {
+ onFilterChange({ view: 'inbox', feedId: undefined });
+ }
+ }}
+ stats={stats}
+ playableCount={playableCount}
+ />
+
+ {/* Slide-In Full-Screen Article Reader Sheet with Left-Edge Swipe Dismiss */}
+ {selectedEntry && (
+ setSelectedEntry(null)}
+ onToggleStar={onToggleStar}
+ onToggleRead={onToggleRead}
+ onPlayAudio={onPlayAudio}
+ currentAudioUrl={currentAudioUrl}
+ isPlayingAudio={isPlayingAudio}
+ />
+ )}
+
+ );
+
+ function renderCard(entry: FeedEntry) {
+ const coverImage = extractFirstImage(entry.content || entry.raw_content);
+ const hasAi = Boolean(entry.variants?.['ai_summary']);
+ const hasTts = Boolean(entry.variants?.['tts'] || entry.variants?.['podcast_tts']);
+
+ return (
+ handleCardClick(entry)}
+ >
+ {/* Cover / SmartArt Preview */}
+ {coverImage ? (
+
+

+
+ ) : (
+
+ )}
+
+ {/* Card Body & Metadata */}
+
+
{entry.title}
+
+
+
+ {entry.feed_title}
+
+
+ {hasAi && }
+ {hasTts && }
+ {formatRelativeDate(entry.published_at)}
+
+
+
+
+ );
+ }
+};
diff --git a/web/src/components/mobile/XHSBottomNav.tsx b/web/src/components/mobile/XHSBottomNav.tsx
new file mode 100644
index 0000000..5b2ab94
--- /dev/null
+++ b/web/src/components/mobile/XHSBottomNav.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Home, Rss, Headphones, Star, Settings } from 'lucide-react';
+import { Stats } from '../../types';
+
+export type MobileTab = 'home' | 'feeds' | 'listen' | 'starred' | 'settings';
+
+interface XHSBottomNavProps {
+ activeTab: MobileTab;
+ onTabChange: (tab: MobileTab) => void;
+ stats: Stats | null;
+ playableCount?: number;
+}
+
+export const XHSBottomNav: React.FC = ({
+ activeTab,
+ onTabChange,
+ stats,
+ playableCount = 0,
+}) => {
+ return (
+
+ );
+};
diff --git a/web/src/components/settings/SettingsView.tsx b/web/src/components/settings/SettingsView.tsx
new file mode 100644
index 0000000..bfe9a5b
--- /dev/null
+++ b/web/src/components/settings/SettingsView.tsx
@@ -0,0 +1,1731 @@
+import React, { useState, useEffect } from 'react';
+import { Category, Feed, Rule, Stats, User, AISettings, PromptTemplate, AITestResult, ViewPreference } from '../../types';
+import { api } from '../../services/api';
+import { FeedIcon } from '../desktop/DesktopSidebar';
+import {
+ Rss,
+ Folder,
+ Trash2,
+ RefreshCw,
+ Plus,
+ CheckCircle2,
+ UploadCloud,
+ Download,
+ Loader2,
+ Edit2,
+ X,
+ Lock,
+ Zap,
+ Bot,
+ ArrowLeft,
+ Sliders,
+ Monitor,
+ Smartphone,
+} from 'lucide-react';
+
+interface SettingsViewProps {
+ currentUser?: User | null;
+ feeds: Feed[];
+ categories: Category[];
+ stats?: Stats | null;
+ onRefreshData: () => void;
+ onOpenAddFeed: () => void;
+ onBack?: () => void;
+ viewPreference: ViewPreference;
+ onChangeViewPreference: (pref: ViewPreference) => void;
+}
+
+interface BuilderCondition {
+ id: string;
+ field: string;
+ operator: string;
+ value: string;
+ selectedFeedIds: string[];
+ selectedCatIds: string[];
+}
+
+export const SettingsView: React.FC = ({
+ currentUser,
+ feeds,
+ categories,
+ stats,
+ onRefreshData,
+ onOpenAddFeed,
+ onBack,
+ viewPreference,
+ onChangeViewPreference,
+}) => {
+ const [activeTab, setActiveTab] = useState<'general' | 'feeds' | 'categories' | 'rules' | 'ai' | 'opml' | 'account'>('general');
+ const [feedSearch, setFeedSearch] = useState('');
+ const [newCatTitle, setNewCatTitle] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState(null);
+
+ // General Preferences State
+ const [autoMarkReadOnScroll, setAutoMarkReadOnScroll] = useState(() => {
+ return localStorage.getItem('rsspipe_auto_read_scroll') === 'true';
+ });
+
+ const handleToggleAutoReadScroll = (checked: boolean) => {
+ setAutoMarkReadOnScroll(checked);
+ localStorage.setItem('rsspipe_auto_read_scroll', checked ? 'true' : 'false');
+ showNotification('Reading preference saved');
+ };
+
+ // 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 [showPromptModal, setShowPromptModal] = useState(false);
+ const [promptForm, setPromptForm] = useState({
+ id: '',
+ name: '',
+ description: '',
+ prompt: '',
+ is_default: false,
+ });
+
+ // 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);
+
+ // Rule Builder Form State
+ 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 [ruleScrapeFullText, setRuleScrapeFullText] = useState(true);
+ const [ruleMarkStarred, setRuleMarkStarred] = useState(false);
+ const [ruleMarkRead, setRuleMarkRead] = useState(false);
+ const [ruleSetVariant, setRuleSetVariant] = useState(false);
+ const [ruleVariantKey, setRuleVariantKey] = useState('tags');
+ const [ruleVariantValue, setRuleVariantValue] = useState('');
+ const [ruleAiEnrich, setRuleAiEnrich] = useState(false);
+ const [ruleAiPrompt, setRuleAiPrompt] = useState('Summarize the key points in 3 bullets.');
+ const [ruleAiAgent, setRuleAiAgent] = useState(false);
+ const [ruleAiAgentPrompt, setRuleAiAgentPrompt] = useState('Analyze importance and triage');
+
+ // Edit Feed State
+ const [editingFeed, setEditingFeed] = useState(null);
+ const [editTitle, setEditTitle] = useState('');
+ const [editIconUrl, setEditIconUrl] = useState('');
+ const [editCategoryId, setEditCategoryId] = useState('');
+ const [editInterval, setEditInterval] = useState(30);
+ const [editTimeout, setEditTimeout] = useState(10);
+
+ // Password State
+ const [oldPassword, setOldPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [passwordLoading, setPasswordLoading] = useState(false);
+
+ useEffect(() => {
+ if (activeTab === 'ai') {
+ loadAISettings();
+ } else if (activeTab === 'rules') {
+ loadRules();
+ }
+ }, [activeTab]);
+
+ const showNotification = (msg: string) => {
+ setMessage(msg);
+ setTimeout(() => setMessage(null), 3500);
+ };
+
+ const loadAISettings = async () => {
+ setAiLoading(true);
+ try {
+ const res = await api.getAISettings();
+ if (res) setAiSettings(res);
+ } catch {
+ // ignore
+ } finally {
+ setAiLoading(false);
+ }
+ };
+
+ const loadRules = async () => {
+ setRulesLoading(true);
+ try {
+ const list = await api.listRules();
+ setRules(list || []);
+ } catch {
+ // ignore
+ } finally {
+ setRulesLoading(false);
+ }
+ };
+
+ const handleSaveAISettings = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setAiSaving(true);
+ try {
+ const updated = await api.updateAISettings(aiSettings);
+ setAiSettings(updated);
+ showNotification('AI Settings saved successfully');
+ } catch (err: any) {
+ alert(`Failed to save AI settings: ${err.message}`);
+ } finally {
+ setAiSaving(false);
+ }
+ };
+
+ const handleTestAIConnection = async () => {
+ setAiTesting(true);
+ setAiTestResult(null);
+ try {
+ const result = await api.testAIConnection(aiSettings);
+ setAiTestResult(result);
+ } catch (err: any) {
+ setAiTestResult({
+ success: false,
+ message: err.message || 'Connection test failed',
+ latency_ms: 0,
+ model: aiSettings.model,
+ });
+ } finally {
+ setAiTesting(false);
+ }
+ };
+
+ // Prompt Templates Handler
+ const handleSavePrompt = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!promptForm.name.trim() || !promptForm.prompt.trim()) return;
+
+ let updatedPrompts = [...(aiSettings.prompts || [])];
+ if (promptForm.id) {
+ updatedPrompts = updatedPrompts.map((p) =>
+ p.id === promptForm.id ? { ...promptForm } : p
+ );
+ } else {
+ updatedPrompts.push({
+ ...promptForm,
+ id: 'prompt-' + Date.now(),
+ });
+ }
+
+ if (promptForm.is_default) {
+ updatedPrompts = updatedPrompts.map((p) => ({
+ ...p,
+ is_default: p.id === promptForm.id || (!promptForm.id && p.id === updatedPrompts[updatedPrompts.length - 1].id),
+ }));
+ }
+
+ const nextSettings = { ...aiSettings, prompts: updatedPrompts };
+ setAiSettings(nextSettings);
+ setShowPromptModal(false);
+ try {
+ await api.updateAISettings(nextSettings);
+ showNotification('Prompt template saved');
+ } catch (err: any) {
+ alert(`Failed to save prompt: ${err.message}`);
+ }
+ };
+
+ const handleDeletePrompt = async (id: string) => {
+ const nextPrompts = aiSettings.prompts.filter((p) => p.id !== id);
+ const nextSettings = { ...aiSettings, prompts: nextPrompts };
+ setAiSettings(nextSettings);
+ try {
+ await api.updateAISettings(nextSettings);
+ showNotification('Prompt template deleted');
+ } catch (err: any) {
+ alert(`Failed to delete prompt: ${err.message}`);
+ }
+ };
+
+ const handleCreateCategory = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newCatTitle.trim()) return;
+ setLoading(true);
+ try {
+ await api.createCategory(newCatTitle.trim());
+ setNewCatTitle('');
+ onRefreshData();
+ showNotification('Category created');
+ } catch (err: any) {
+ alert(err.message || 'Failed to create category');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDeleteCategory = async (id: string) => {
+ if (!confirm('Are you sure? Feeds in this category will become uncategorized.')) return;
+ try {
+ await api.deleteCategory(id);
+ onRefreshData();
+ showNotification('Category deleted');
+ } catch (err: any) {
+ alert(err.message || 'Failed to delete category');
+ }
+ };
+
+ const handleSaveFeed = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!editingFeed) return;
+ setLoading(true);
+ try {
+ await api.updateFeed(editingFeed.id, {
+ title: editTitle,
+ icon_url: editIconUrl,
+ category_id: editCategoryId || undefined,
+ refresh_interval_minutes: editInterval,
+ timeout_seconds: editTimeout,
+ });
+ setEditingFeed(null);
+ onRefreshData();
+ showNotification('Feed updated successfully');
+ } catch (err: any) {
+ alert(err.message || 'Failed to update feed');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDeleteFeed = async (id: string) => {
+ if (!confirm('Are you sure you want to delete this feed subscription and its articles?')) return;
+ try {
+ await api.deleteFeed(id);
+ onRefreshData();
+ showNotification('Feed deleted');
+ } catch (err: any) {
+ alert(err.message || 'Failed to delete feed');
+ }
+ };
+
+ const handleRefreshSingleFeed = async (id: string) => {
+ try {
+ await api.refreshFeed(id);
+ onRefreshData();
+ showNotification('Feed refresh triggered');
+ } catch (err: any) {
+ alert(err.message || 'Failed to refresh feed');
+ }
+ };
+
+ // Rule Builder Actions
+ const handleSaveRule = async (applyImmediately: boolean = false) => {
+ if (!ruleName.trim()) {
+ alert('Please enter a rule name');
+ return;
+ }
+
+ const actions: any[] = [];
+ if (ruleScrapeFullText) actions.push({ type: 'scrape_full_text' });
+ if (ruleMarkStarred) actions.push({ type: 'mark_starred' });
+ if (ruleMarkRead) actions.push({ type: 'mark_read' });
+ if (ruleSetVariant) {
+ actions.push({
+ type: 'set_variant',
+ params: { variant_key: ruleVariantKey, value: ruleVariantValue, content_type: 'text' },
+ });
+ }
+ if (ruleAiEnrich) {
+ actions.push({
+ type: 'ai_enrich',
+ params: { prompt: ruleAiPrompt, variant_key: 'ai_summary' },
+ });
+ }
+ if (ruleAiAgent) {
+ actions.push({
+ type: 'ai_agent_action',
+ params: { prompt: ruleAiAgentPrompt, allowed_actions: ['mark_read', 'mark_starred'] },
+ });
+ }
+
+ const conditions = builderConditions.map((c) => {
+ let finalVal: any = c.value;
+ if (c.field === 'feed_id') {
+ finalVal = c.selectedFeedIds;
+ } else if (c.field === 'category_id') {
+ finalVal = c.selectedCatIds;
+ }
+ return {
+ field: c.field,
+ operator: c.operator,
+ value: finalVal,
+ };
+ });
+
+ setSaveLoading(true);
+ try {
+ const payload = {
+ name: ruleName,
+ trigger: ruleTrigger,
+ match_mode: ruleMatchMode,
+ conditions,
+ actions,
+ is_active: true,
+ };
+
+ let ruleId = editingRuleId;
+ if (editingRuleId) {
+ await api.updateRule(editingRuleId, payload);
+ } else {
+ const created = await api.createRule(payload);
+ ruleId = created.id;
+ }
+
+ if (applyImmediately && ruleId) {
+ const applyRes = await api.applyRule(ruleId);
+ showNotification(`Rule saved & applied: ${applyRes.matched_count} matches, ${applyRes.queued_jobs} jobs`);
+ } else {
+ showNotification('Rule saved successfully');
+ }
+
+ setShowRuleModal(false);
+ setEditingRuleId(null);
+ loadRules();
+ } catch (err: any) {
+ alert(`Failed to save rule: ${err.message}`);
+ } finally {
+ setSaveLoading(false);
+ }
+ };
+
+ const handleDeleteRule = async (id: string) => {
+ if (!confirm('Delete this rule?')) return;
+ try {
+ await api.deleteRule(id);
+ loadRules();
+ showNotification('Rule deleted');
+ } catch (err: any) {
+ alert(err.message || 'Failed to delete rule');
+ }
+ };
+
+ const handleApplyRule = async (id: string) => {
+ try {
+ const res = await api.applyRule(id);
+ showNotification(`Rule applied: ${res.matched_count} matches, ${res.queued_jobs} jobs`);
+ } catch (err: any) {
+ alert(err.message || 'Failed to apply rule');
+ }
+ };
+
+ const handleChangePassword = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (newPassword !== confirmPassword) {
+ alert('New passwords do not match');
+ return;
+ }
+ setPasswordLoading(true);
+ try {
+ await api.changePassword(oldPassword, newPassword);
+ setOldPassword('');
+ setNewPassword('');
+ setConfirmPassword('');
+ showNotification('Password updated successfully');
+ } catch (err: any) {
+ alert(err.message || 'Failed to update password');
+ } finally {
+ setPasswordLoading(false);
+ }
+ };
+
+ const filteredFeeds = feeds.filter(
+ (f) =>
+ f.title.toLowerCase().includes(feedSearch.toLowerCase()) ||
+ f.feed_url.toLowerCase().includes(feedSearch.toLowerCase())
+ );
+
+ return (
+
+ {/* Settings Navigation Sidebar */}
+
+
+ {/* Fluid Full-Width Settings Content Pane */}
+
+ {/* Banner with Return button */}
+
+
+
+ RSSPipe Gateway / Settings
+
+
+ {activeTab === 'general' && 'General Preferences'}
+ {activeTab === 'feeds' && 'Feed Subscriptions'}
+ {activeTab === 'categories' && 'Category Management'}
+ {activeTab === 'rules' && 'Automation & Triage Rules'}
+ {activeTab === 'ai' && 'AI Provider & Prompt Engineering'}
+ {activeTab === 'opml' && 'OPML Migration & Backup'}
+ {activeTab === 'account' && 'Account & System Overview'}
+
+
+
+ {onBack && (
+
+
+ Back to Articles
+
+ )}
+
+
+ {message && (
+
+
+ {message}
+
+ )}
+
+ {/* Tab 0: General Preferences */}
+ {activeTab === 'general' && (
+
+
+ Configure your default view layout and reading behavior.
+
+
+ {/* View Layout Mode Preference */}
+
+
View Layout Preference
+
+ Choose how RSSPipe adapts to your screen and devices.
+
+
+
+ {/* Auto Option */}
+
{
+ onChangeViewPreference('auto');
+ showNotification('Set view layout to Automatic (Responsive)');
+ }}
+ >
+
+
+
+ Automatic (Responsive)
+
+ {viewPreference === 'auto' && Active}
+
+
+ Automatically switches to 3-Split on desktop/pad screens (≥ 768px) and Waterfall on mobile touch devices.
+
+
+
+ {/* Desktop Option */}
+
{
+ onChangeViewPreference('desktop');
+ showNotification('Locked view layout to Desktop 3-Split');
+ }}
+ >
+
+
+
+ Desktop (3-Split)
+
+ {viewPreference === 'desktop' && Active}
+
+
+ Always displays the resizable 3-pane power workspace with fast keyboard navigation.
+
+
+
+ {/* Mobile Option */}
+
{
+ onChangeViewPreference('mobile');
+ showNotification('Locked view layout to Mobile Waterfall');
+ }}
+ >
+
+
+
+ Mobile (Waterfall)
+
+ {viewPreference === 'mobile' && Active}
+
+
+ Always displays the Xiaohongshu-style visual discovery feed with iOS bottom navigation.
+
+
+
+
+
+ {/* Reading Preferences */}
+
+
Reading Preferences
+
+
+
+
+
+
+ )}
+
+ {/* Tab 1: Feeds */}
+ {activeTab === 'feeds' && (
+
+
+
+ Manage feed subscriptions, update polling frequencies, and organize sources.
+
+
+
+ Add Feed
+
+
+
+
+ setFeedSearch(e.target.value)}
+ />
+
+
+
+ {filteredFeeds.map((feed) => (
+
+
+
+
+
+ {feed.title}
+
+
+ {feed.feed_url}
+
+
+
+
+
+ handleRefreshSingleFeed(feed.id)}
+ title="Refresh feed"
+ >
+
+
+ {
+ setEditingFeed(feed);
+ setEditTitle(feed.title);
+ setEditIconUrl(feed.icon_url || '');
+ setEditCategoryId(feed.category_id || '');
+ setEditInterval(feed.refresh_interval_minutes || 30);
+ setEditTimeout(feed.timeout_seconds || 10);
+ }}
+ title="Edit feed"
+ >
+
+
+ handleDeleteFeed(feed.id)}
+ title="Delete feed"
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Tab 2: Categories */}
+ {activeTab === 'categories' && (
+
+
Create and organize feed folders and taxonomy.
+
+
+
+
+ {categories.map((cat) => (
+
+
+
+ {cat.title}
+
+
handleDeleteCategory(cat.id)}
+ title="Delete Category"
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Tab 3: Automation Rules */}
+ {activeTab === 'rules' && (
+
+
+
+ Set up automated event hooks for Readability scraping, tagging, and AI autonomous triage.
+
+
{
+ setEditingRuleId(null);
+ setRuleName('');
+ setBuilderConditions([{ id: 'cond-1', field: 'feed_id', operator: 'in', value: '', selectedFeedIds: [], selectedCatIds: [] }]);
+ setShowRuleModal(true);
+ }}
+ >
+
+ New Rule
+
+
+
+ {rulesLoading ? (
+
+
+
+ ) : rules.length === 0 ? (
+
+
+
No Automation Rules
+
+ Create rules to automatically scrape full-text, star keywords, or generate AI summaries.
+
+
+ ) : (
+
+ {rules.map((rule) => (
+
+
+
+ {rule.name}
+
+
+ Trigger: {rule.trigger} • Actions: {rule.actions.map((a) => a.type).join(', ')}
+
+
+
+
+ handleApplyRule(rule.id)}
+ >
+ Apply Now
+
+ handleDeleteRule(rule.id)}
+ title="Delete Rule"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Tab 4: AI Settings & Prompt Templates */}
+ {activeTab === 'ai' && (
+
+
+ Connect external or local LLMs (OpenAI, Gemini, DeepSeek, Ollama) and configure prompt templates.
+
+
+ {aiLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+ {/* Prompt Templates Manager */}
+
+
+
+
Prompt Templates
+
+ Reusable instructions for article summaries and autonomous classification.
+
+
+
{
+ setPromptForm({ id: '', name: '', description: '', prompt: '', is_default: false });
+ setShowPromptModal(true);
+ }}
+ >
+
+ Add Prompt
+
+
+
+ {(!aiSettings.prompts || aiSettings.prompts.length === 0) ? (
+
+ No custom prompts created. Default summary prompt will be used.
+
+ ) : (
+ aiSettings.prompts.map((p) => (
+
+
+
+ {p.name}
+ {p.is_default && Default}
+
+
+ {
+ setPromptForm(p);
+ setShowPromptModal(true);
+ }}
+ title="Edit Prompt"
+ >
+
+
+ handleDeletePrompt(p.id)}
+ title="Delete Prompt"
+ >
+
+
+
+
+ {p.description && (
+
+ {p.description}
+
+ )}
+
+ {p.prompt}
+
+
+ ))
+ )}
+
+ >
+ )}
+
+ )}
+
+ {/* Tab 5: OPML */}
+ {activeTab === 'opml' && (
+
+
Migrate your subscriptions seamlessly to and from RSSPipe.
+
+
+
Export Subscriptions
+
+ Download your feeds and folders in standard OPML 2.0 XML format.
+
+
+
+ Export OPML
+
+
+
+ )}
+
+ {/* Tab 6: Account & System Overview */}
+ {activeTab === 'account' && (
+
+
System diagnostics, single-user profile, and security credentials.
+
+ {/* System Stats Overview */}
+
+
+
{stats?.total_feeds ?? feeds.length}
+
Feed Subscriptions
+
+
+
{stats?.total_unread ?? 0}
+
Unread Articles
+
+
+
{stats?.total_starred ?? 0}
+
Starred Articles
+
+
+
{stats?.total_categories ?? categories.length}
+
Categories
+
+
+
+ {/* User Profile Info */}
+
+
Administrator Profile
+
+
Username: {currentUser?.username || 'admin'}
+ {currentUser?.id &&
User ID: {currentUser.id}
}
+ {currentUser?.created_at && (
+
Account Created: {new Date(currentUser.created_at).toLocaleDateString()}
+ )}
+
Status: Active Administrator
+
+
+
+ {/* Change Password */}
+
+
+ )}
+
+
+ {/* Edit Feed Modal */}
+ {editingFeed && (
+
setEditingFeed(null)}>
+
e.stopPropagation()}>
+
+
Edit Feed
+ setEditingFeed(null)}>
+
+
+
+
+
+
+ )}
+
+ {/* Prompt Modal */}
+ {showPromptModal && (
+
setShowPromptModal(false)}>
+
e.stopPropagation()}>
+
+
{promptForm.id ? 'Edit Prompt Template' : 'New Prompt Template'}
+ setShowPromptModal(false)}>
+
+
+
+
+
+
+ )}
+
+ {/* Comprehensive Rule Builder Modal */}
+ {showRuleModal && (
+
setShowRuleModal(false)}>
+
e.stopPropagation()}>
+
+
{editingRuleId ? 'Edit Rule' : 'Create Automation Rule'}
+ setShowRuleModal(false)}>
+
+
+
+
+
+
+ setRuleName(e.target.value)}
+ required
+ />
+
+
+ {/* Trigger */}
+
+
+
+
+
+ {ruleTrigger === 'on_variant_updated' && (
+
+
+
+
+
+
+
+ setTargetVariantKey(e.target.value)}
+ placeholder="e.g. keywords, ai_summary"
+ />
+
+
+ )}
+
+ {/* Match Mode */}
+
+
+
+
+
+ {/* Conditions List */}
+
+
+
+
+ setBuilderConditions([
+ ...builderConditions,
+ { id: 'cond-' + Date.now(), field: 'title', operator: 'contains', value: '', selectedFeedIds: [], selectedCatIds: [] },
+ ])
+ }
+ >
+ Add Condition
+
+
+
+ {builderConditions.map((cond, idx) => (
+
+
+
+
+
+ {cond.field === 'feed_id' ? (
+
+ ) : cond.field === 'category_id' ? (
+
+ ) : (
+ {
+ const next = [...builderConditions];
+ next[idx].value = e.target.value;
+ setBuilderConditions(next);
+ }}
+ />
+ )}
+
+ {builderConditions.length > 1 && (
+ setBuilderConditions(builderConditions.filter((c) => c.id !== cond.id))}
+ >
+
+
+ )}
+
+ ))}
+
+
+ {/* Actions Section */}
+
+
+
+
+ setShowRuleModal(false)}>
+ Cancel
+
+ handleSaveRule(true)}
+ disabled={saveLoading || !ruleName.trim()}
+ >
+ Save & Apply to Existing
+
+ handleSaveRule(false)}
+ disabled={saveLoading || !ruleName.trim()}
+ >
+ {saveLoading ? : 'Save Rule'}
+
+
+
+
+ )}
+
+ );
+};
diff --git a/web/src/index.css b/web/src/index.css
index 4d40ef0..18bd633 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -1,1312 +1,8 @@
-/* RSSPipe - Modern Minimal High-Contrast Design System (Light Mode) */
-
-:root {
- --bg-app: #f8fafc;
- --bg-surface: #ffffff;
- --bg-sidebar: #f8fafc;
- --bg-list: #ffffff;
- --bg-reader: #ffffff;
- --bg-card: #ffffff;
- --bg-card-hover: #f1f5f9;
- --bg-card-selected: #e2e8f0;
-
- --border: #e2e8f0;
- --border-light: #f1f5f9;
- --border-dark: #cbd5e1;
- --border-focus: #0f172a;
-
- --text-main: #0f172a;
- --text-secondary: #334155;
- --text-muted: #64748b;
- --text-dim: #94a3b8;
-
- --accent: #0f172a;
- --accent-hover: #1e293b;
- --accent-soft: rgba(15, 23, 42, 0.06);
- --tag-bg: #f1f5f9;
- --tag-text: #0f172a;
- --tag-border: #cbd5e1;
-
- --star: #d97706;
- --star-soft: #fef3c7;
- --unread-dot: #0f172a;
- --success: #15803d;
- --danger: #b91c1c;
-
- --radius-xs: 4px;
- --radius-sm: 6px;
- --radius-md: 10px;
- --radius-lg: 16px;
-
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
- --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
- --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.1);
-
- --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- --font-mono: 'JetBrains Mono', monospace;
-}
-
-* {
- box-sizing: border-box;
- margin: 0;
- padding: 0;
-}
-
-html, body, #root {
- height: 100%;
- width: 100%;
- overflow: hidden;
- font-family: var(--font-sans);
- background-color: var(--bg-app);
- color: var(--text-main);
- -webkit-font-smoothing: antialiased;
-}
-
-/* Custom Scrollbars */
-::-webkit-scrollbar {
- width: 5px;
- height: 5px;
-}
-::-webkit-scrollbar-track {
- background: transparent;
-}
-::-webkit-scrollbar-thumb {
- background: var(--border-dark);
- border-radius: 4px;
-}
-::-webkit-scrollbar-thumb:hover {
- background: var(--text-muted);
-}
-
-/* Base App Layout */
-.app-container {
- display: flex;
- flex-direction: column;
- height: 100vh;
- width: 100vw;
- overflow: hidden;
- background: var(--bg-app);
-}
-
-/* Top Navigation Header */
-.app-header {
- height: 50px;
- min-height: 50px;
- background: var(--bg-surface);
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 0.85rem;
- z-index: 30;
- gap: 0.75rem;
-}
-
-.logo-area {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- font-weight: 700;
- font-size: 1.05rem;
- letter-spacing: -0.02em;
- color: var(--text-main);
- user-select: none;
- cursor: pointer;
-}
-
-.logo-badge {
- background: var(--accent);
- color: #ffffff;
- font-size: 0.62rem;
- font-weight: 700;
- padding: 2px 5px;
- border-radius: 4px;
- letter-spacing: 0.04em;
- text-transform: uppercase;
-}
-
-.search-bar-container {
- flex: 1;
- max-width: 480px;
- position: relative;
- display: flex;
- align-items: center;
-}
-
-.search-input {
- width: 100%;
- background: var(--bg-app);
- border: 1px solid var(--border);
- border-radius: 20px;
- padding: 0.38rem 0.9rem 0.38rem 2rem;
- color: var(--text-main);
- font-size: 0.84rem;
- outline: none;
- transition: all 0.15s ease;
-}
-
-.search-input:focus {
- border-color: var(--border-focus);
- background: #ffffff;
- box-shadow: 0 0 0 2px var(--accent-soft);
-}
-
-.search-icon {
- position: absolute;
- left: 0.7rem;
- color: var(--text-muted);
- pointer-events: none;
-}
-
-.search-shortcut-hint {
- position: absolute;
- right: 0.75rem;
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: 4px;
- font-size: 0.65rem;
- padding: 1px 5px;
- color: var(--text-muted);
- font-family: var(--font-mono);
- pointer-events: none;
-}
-
-.header-actions {
- display: flex;
- align-items: center;
- gap: 0.35rem;
-}
-
-/* Button & Pill styles */
-.btn-icon {
- background: transparent;
- border: 1px solid transparent;
- color: var(--text-secondary);
- width: 32px;
- height: 32px;
- border-radius: var(--radius-sm);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- transition: all 0.1s ease;
-}
-
-.btn-icon:hover {
- background: var(--bg-card-hover);
- color: var(--text-main);
- border-color: var(--border);
-}
-
-.btn-icon.active {
- background: var(--accent);
- color: #ffffff;
-}
-
-.btn-primary {
- background: var(--accent);
- color: #ffffff;
- border: 1px solid var(--accent);
- font-weight: 600;
- font-size: 0.8rem;
- padding: 0.38rem 0.75rem;
- border-radius: var(--radius-sm);
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- cursor: pointer;
- transition: background 0.1s ease;
- white-space: nowrap;
-}
-
-.btn-primary:hover {
- background: var(--accent-hover);
-}
-
-.btn-secondary {
- background: var(--bg-surface);
- color: var(--text-main);
- border: 1px solid var(--border);
- font-weight: 500;
- font-size: 0.8rem;
- padding: 0.38rem 0.75rem;
- border-radius: var(--radius-sm);
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- cursor: pointer;
- transition: all 0.1s ease;
- white-space: nowrap;
-}
-
-.btn-secondary:hover {
- background: var(--bg-card-hover);
- border-color: var(--border-dark);
-}
-
-.view-mode-pill {
- display: flex;
- background: var(--bg-app);
- border: 1px solid var(--border);
- border-radius: var(--radius-sm);
- padding: 2px;
- gap: 2px;
-}
-
-.tag-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 7px;
- border-radius: 4px;
- font-size: 0.72rem;
- font-weight: 600;
- background: var(--tag-bg);
- color: var(--tag-text);
- border: 1px solid var(--tag-border);
-}
-
-.variant-tag-chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 8px;
- border-radius: 9999px;
- font-size: 0.7rem;
- font-weight: 500;
- background: rgba(16, 185, 129, 0.08);
- color: #34d399;
- border: 1px solid rgba(16, 185, 129, 0.22);
- white-space: nowrap;
- line-height: 1.45;
- transition: all 0.15s ease;
-}
-
-.variant-tag-chip:hover {
- background: rgba(16, 185, 129, 0.15);
- border-color: rgba(16, 185, 129, 0.35);
-}
-
-/* Mobile Dropdown Menu */
-.mobile-menu-dropdown {
- position: absolute;
- top: 48px;
- right: 10px;
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- box-shadow: var(--shadow-lg);
- width: 200px;
- padding: 0.4rem;
- z-index: 50;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.mobile-menu-item {
- display: flex;
- align-items: center;
- gap: 0.6rem;
- padding: 0.6rem 0.75rem;
- border-radius: var(--radius-sm);
- font-size: 0.85rem;
- font-weight: 500;
- color: var(--text-main);
- background: transparent;
- border: none;
- cursor: pointer;
- text-align: left;
- width: 100%;
-}
-
-.mobile-menu-item:hover {
- background: var(--bg-card-hover);
-}
-
-/* ========================================================================= */
-/* 1. REEDER 4 MINIMAL 3-PANE SPLIT VIEW (DESKTOP) */
-/* ========================================================================= */
-
-.three-pane-container {
- display: flex;
- flex: 1;
- height: calc(100vh - 50px);
- overflow: hidden;
- background: var(--bg-app);
-}
-
-/* Sidebar (Pane 1) - Independent Scrollable */
-.sidebar-pane {
- width: 240px;
- min-width: 240px;
- background: var(--bg-sidebar);
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
- transition: width 0.2s ease, min-width 0.2s ease;
-}
-
-.sidebar-pane.collapsed {
- width: 0;
- min-width: 0;
- border-right: none;
- overflow: hidden;
-}
-
-.sidebar-scroll-area {
- flex: 1 1 0%;
- height: 100%;
- overflow-y: auto;
- overscroll-behavior: contain;
- padding: 0.75rem 0.5rem 2rem 0.5rem;
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.sidebar-nav-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.45rem 0.65rem;
- border-radius: var(--radius-sm);
- color: var(--text-secondary);
- font-size: 0.825rem;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.1s ease;
-}
-
-.sidebar-nav-item:hover {
- background: var(--bg-card-hover);
- color: var(--text-main);
-}
-
-.sidebar-nav-item.active {
- background: var(--bg-card-selected);
- color: var(--text-main);
- font-weight: 600;
-}
-
-.sidebar-nav-label {
- display: flex;
- align-items: center;
- gap: 0.55rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.sidebar-section-header {
- font-size: 0.68rem;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-muted);
- padding: 0.85rem 0.65rem 0.25rem 0.65rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.feed-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0.35rem 0.65rem 0.35rem 1.4rem;
- border-radius: var(--radius-sm);
- color: var(--text-secondary);
- font-size: 0.79rem;
- cursor: pointer;
- transition: all 0.1s ease;
-}
-
-.feed-item:hover {
- background: var(--bg-card-hover);
- color: var(--text-main);
-}
-
-.feed-item.active {
- background: var(--bg-card-selected);
- color: var(--text-main);
- font-weight: 600;
-}
-
-.feed-title-text {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.badge {
- font-size: 0.68rem;
- font-weight: 600;
- padding: 1px 6px;
- border-radius: 10px;
- background: var(--border);
- color: var(--text-muted);
-}
-
-.badge.unread {
- background: var(--accent);
- color: #ffffff;
-}
-
-/* Article List (Pane 2) - Independent Scrollable */
-.article-list-pane {
- width: 350px;
- min-width: 300px;
- max-width: 400px;
- background: var(--bg-list);
- border-right: 1px solid var(--border);
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
-}
-
-.article-list-header {
- height: 44px;
- min-height: 44px;
- padding: 0 0.85rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
- border-bottom: 1px solid var(--border);
- background: var(--bg-list);
-}
-
-.article-list-scroll-area {
- flex: 1 1 0%;
- height: 100%;
- overflow-y: auto;
- overscroll-behavior: contain;
-}
-
-.article-item-card {
- padding: 0.85rem 1rem;
- border-bottom: 1px solid var(--border-light);
- cursor: pointer;
- display: flex;
- flex-direction: column;
- gap: 0.3rem;
- position: relative;
- transition: background 0.1s ease;
- user-select: none;
-}
-
-.article-item-card:hover {
- background: var(--bg-card-hover);
-}
-
-.article-item-card.selected {
- background: var(--bg-card-selected);
-}
-
-.article-item-card.selected::before {
- content: '';
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 3px;
- background: var(--accent);
-}
-
-.article-item-top {
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 0.74rem;
- color: var(--text-muted);
-}
-
-.article-item-source {
- font-weight: 600;
- color: var(--text-main);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 200px;
-}
-
-.article-item-title {
- font-size: 0.88rem;
- font-weight: 600;
- line-height: 1.35;
- color: var(--text-main);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.article-item-card.is-read .article-item-title {
- font-weight: 400;
- color: var(--text-secondary);
-}
-
-.article-item-snippet {
- font-size: 0.76rem;
- line-height: 1.4;
- color: var(--text-muted);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.unread-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--unread-dot);
- flex-shrink: 0;
-}
-
-/* Reader Canvas (Pane 3) - Independent Scrollable */
-.reader-pane {
- flex: 1 1 0%;
- height: 100%;
- background: var(--bg-reader);
- overflow-y: auto;
- overscroll-behavior: contain;
- display: flex;
- flex-direction: column;
-}
-
-.reader-toolbar {
- position: sticky;
- top: 0;
- height: 46px;
- min-height: 46px;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(8px);
- border-bottom: 1px solid var(--border);
- padding: 0 1.25rem;
- display: flex;
- align-items: center;
- justify-content: space-between;
- z-index: 10;
-}
-
-/* Sliding Ball Capsule Toggle Switch */
-.capsule-switch-btn {
- display: inline-flex;
- align-items: center;
- border: none;
- background: transparent;
- padding: 0;
- cursor: pointer;
- outline: none;
- user-select: none;
- margin-left: 0.5rem;
-}
-
-.capsule-switch-track {
- position: relative;
- display: flex;
- align-items: center;
- width: 86px;
- height: 26px;
- border-radius: 9999px;
- background-color: var(--bg-card-hover);
- border: 1px solid var(--border-dark);
- padding: 2px;
- transition: background-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
- border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1),
- box-shadow 0.25s ease;
- box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
-}
-
-.capsule-switch-btn:hover .capsule-switch-track {
- border-color: var(--border-focus);
-}
-
-.capsule-switch-btn.active .capsule-switch-track {
- background-color: #0284c7;
- border-color: #0284c7;
-}
-
-.capsule-switch-btn.scraping .capsule-switch-track {
- background-color: #0369a1;
- border-color: #0369a1;
- cursor: wait;
-}
-
-.capsule-switch-thumb {
- position: absolute;
- top: 2px;
- left: 2px;
- width: 20px;
- height: 20px;
- border-radius: 50%;
- background-color: #ffffff;
- color: #64748b;
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1);
- transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
- color 0.25s ease;
- z-index: 2;
-}
-
-.capsule-switch-btn.active .capsule-switch-thumb {
- transform: translateX(60px);
- color: #0284c7;
-}
-
-.capsule-switch-label {
- width: 100%;
- font-size: 0.69rem;
- font-weight: 600;
- text-align: center;
- transition: color 0.25s ease, padding 0.25s ease;
- z-index: 1;
- white-space: nowrap;
- letter-spacing: -0.01em;
-}
-
-/* When thumb is on the left, label is padded to the right */
-.capsule-switch-btn:not(.active) .capsule-switch-label {
- padding-left: 17px;
- padding-right: 2px;
- color: var(--text-main);
-}
-
-/* When thumb is on the right, label is padded to the left */
-.capsule-switch-btn.active .capsule-switch-label {
- padding-right: 17px;
- padding-left: 2px;
- color: #ffffff;
-}
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-.spin {
- animation: spin 1s linear infinite;
-}
-
-.reader-content-canvas {
- max-width: 680px;
- width: 100%;
- margin: 0 auto;
- padding: 2rem 1.5rem 5rem 1.5rem;
-}
-
-.article-header {
- margin-bottom: 1.75rem;
- padding-bottom: 1.25rem;
- border-bottom: 1px solid var(--border);
-}
-
-.reader-source-bar,
-.article-header-meta {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem 0.65rem;
- font-size: 0.78rem;
- color: var(--text-muted);
- margin-bottom: 0.85rem;
- line-height: 1.5;
-}
-
-.article-feed-badge {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 3px 8px;
- border-radius: 6px;
- font-size: 0.74rem;
- font-weight: 600;
- background: var(--bg-card);
- color: var(--text-main);
- border: 1px solid var(--border);
-}
-
-.article-meta-item {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- color: var(--text-muted);
- font-size: 0.75rem;
-}
-
-.article-meta-divider {
- color: var(--border-dark, #3f3f46);
- font-size: 0.65rem;
- opacity: 0.6;
-}
-
-.article-tags-row {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.4rem;
- margin-top: 0.85rem;
-}
-
-.reader-headline {
- font-size: 1.85rem;
- font-weight: 800;
- line-height: 1.28;
- letter-spacing: -0.02em;
- color: var(--text-main);
- margin-bottom: 0.5rem;
-}
-
-.reader-headline a {
- color: inherit;
- text-decoration: none;
-}
-
-.reader-headline a:hover {
- text-decoration: underline;
-}
-
-.reader-body {
- font-size: 1rem;
- line-height: 1.8;
- color: var(--text-secondary);
- overflow-wrap: break-word;
-}
-
-.reader-body p {
- margin-bottom: 1.25rem;
-}
-
-.reader-body h2, .reader-body h3 {
- color: var(--text-main);
- margin-top: 1.8rem;
- margin-bottom: 0.75rem;
- line-height: 1.35;
-}
-
-.reader-body img {
- max-width: 100%;
- height: auto;
- border-radius: var(--radius-sm);
- margin: 1.25rem 0;
- border: 1px solid var(--border);
-}
-
-.reader-body pre {
- background: var(--bg-app);
- border: 1px solid var(--border);
- padding: 1rem;
- border-radius: var(--radius-sm);
- font-family: var(--font-mono);
- font-size: 0.85rem;
- overflow-x: auto;
- margin: 1.25rem 0;
-}
-
-.reader-body a {
- color: var(--accent);
- text-decoration: underline;
- text-underline-offset: 3px;
-}
-
-/* ========================================================================= */
-/* 2. TRUE XIAOHONGSHU WATERFALL (MASONRY) VIEW (NO SIDEBAR) */
-/* ========================================================================= */
-
-.xhs-waterfall-container {
- display: flex;
- flex-direction: column;
- flex: 1;
- height: calc(100vh - 50px);
- overflow: hidden;
- background: var(--bg-app);
-}
-
-/* Top Horizontal Category Tabs (Xiaohongshu style) */
-.xhs-tabs-bar {
- height: 42px;
- min-height: 42px;
- background: var(--bg-surface);
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- padding: 0 0.85rem;
- overflow-x: auto;
- white-space: nowrap;
- gap: 0.4rem;
- scrollbar-width: none;
-}
-
-.xhs-tabs-bar::-webkit-scrollbar {
- display: none;
-}
-
-.xhs-tab-item {
- padding: 0.3rem 0.75rem;
- border-radius: 20px;
- font-size: 0.8rem;
- font-weight: 500;
- color: var(--text-secondary);
- background: transparent;
- border: 1px solid transparent;
- cursor: pointer;
- transition: all 0.12s ease;
- user-select: none;
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.xhs-tab-item:hover {
- background: var(--bg-card-hover);
- color: var(--text-main);
-}
-
-.xhs-tab-item.active {
- background: var(--accent);
- color: #ffffff;
- font-weight: 600;
-}
-
-.xhs-waterfall-scroll {
- flex: 1 1 0%;
- overflow-y: auto;
- padding: 0.75rem;
-}
-
-/* True CSS / Multi-Column Masonry (Staggered Heights) */
-.xhs-masonry {
- column-count: 2;
- column-gap: 10px;
- max-width: 1400px;
- margin: 0 auto;
-}
-
-@media (min-width: 640px) {
- .xhs-masonry {
- column-count: 3;
- column-gap: 12px;
- }
-}
-
-@media (min-width: 1024px) {
- .xhs-masonry {
- column-count: 4;
- column-gap: 14px;
- }
-}
-
-@media (min-width: 1400px) {
- .xhs-masonry {
- column-count: 5;
- column-gap: 16px;
- }
-}
-
-.xhs-masonry-item {
- break-inside: avoid;
- margin-bottom: 10px;
-}
-
-@media (min-width: 640px) {
- .xhs-masonry-item {
- margin-bottom: 14px;
- }
-}
-
-.xhs-card {
- background: var(--bg-card);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- overflow: hidden;
- cursor: pointer;
- display: flex;
- flex-direction: column;
- transition: transform 0.15s ease, box-shadow 0.15s ease;
-}
-
-.xhs-card:hover {
- transform: translateY(-2px);
- box-shadow: var(--shadow-md);
- border-color: var(--border-dark);
-}
-
-.xhs-card.is-read {
- opacity: 0.75;
-}
-
-.xhs-cover-wrap {
- width: 100%;
- background: var(--bg-app);
- overflow: hidden;
- position: relative;
-}
-
-.xhs-cover-img {
- width: 100%;
- display: block;
- object-fit: cover;
- max-height: 280px;
-}
-
-.xhs-cover-fallback {
- width: 100%;
- height: 90px;
- display: flex;
- align-items: center;
- justify-content: center;
- background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
- color: var(--text-muted);
- padding: 0.75rem;
- text-align: center;
-}
-
-.xhs-card-body {
- padding: 0.65rem 0.75rem;
- display: flex;
- flex-direction: column;
- gap: 0.3rem;
-}
-
-.xhs-card-title {
- font-size: 0.85rem;
- font-weight: 700;
- line-height: 1.35;
- color: var(--text-main);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.xhs-card-snippet {
- font-size: 0.72rem;
- line-height: 1.38;
- color: var(--text-muted);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.xhs-card-footer {
- margin-top: 0.25rem;
- padding-top: 0.4rem;
- border-top: 1px solid var(--border-light);
- display: flex;
- align-items: center;
- justify-content: space-between;
- font-size: 0.7rem;
- color: var(--text-muted);
-}
-
-.xhs-publisher-badge {
- font-weight: 600;
- color: var(--text-secondary);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 110px;
-}
-
-/* Mobile Post Reader (Full Page Slide with Back Arrow) */
-.mobile-post-reader {
- position: fixed;
- inset: 0;
- background: #ffffff;
- z-index: 100;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.mobile-post-reader-header {
- height: 48px;
- min-height: 48px;
- background: #ffffff;
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 0.85rem;
- z-index: 10;
-}
-
-.mobile-post-reader-content {
- flex: 1 1 0%;
- overflow-y: auto;
- padding: 1.25rem 1rem 5rem 1rem;
-}
-
-/* ========================================================================= */
-/* 3. SETTINGS VIEW PAGE - BEAUTIFUL, MODERN, RESPONSIVE */
-/* ========================================================================= */
-
-.settings-container {
- display: flex;
- flex: 1;
- height: calc(100vh - 50px);
- overflow: hidden;
- background: var(--bg-app);
-}
-
-@media (max-width: 768px) {
- .settings-container {
- flex-direction: column;
- }
-}
-
-.settings-nav {
- width: 220px;
- min-width: 220px;
- background: var(--bg-surface);
- border-right: 1px solid var(--border);
- padding: 1rem 0.5rem;
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
-}
-
-@media (max-width: 768px) {
- .settings-nav {
- width: 100%;
- min-width: 100%;
- height: 44px;
- min-height: 44px;
- padding: 0 0.5rem;
- flex-direction: row;
- border-right: none;
- border-bottom: 1px solid var(--border);
- overflow-x: auto;
- white-space: nowrap;
- align-items: center;
- }
-}
-
-.settings-nav-item {
- display: flex;
- align-items: center;
- gap: 0.55rem;
- padding: 0.5rem 0.75rem;
- border-radius: var(--radius-sm);
- font-size: 0.825rem;
- font-weight: 500;
- color: var(--text-secondary);
- background: transparent;
- border: 1px solid transparent;
- cursor: pointer;
- transition: all 0.1s ease;
- white-space: nowrap;
-}
-
-.settings-nav-item:hover {
- background: var(--bg-card-hover);
- color: var(--text-main);
-}
-
-.settings-nav-item.active {
- background: var(--accent);
- color: #ffffff;
- font-weight: 600;
-}
-
-.settings-content-area {
- flex: 1 1 0%;
- width: 100%;
- overflow-y: auto;
- padding: 1.25rem 1.5rem;
-}
-
-@media (min-width: 1024px) {
- .settings-content-area {
- padding: 1.75rem 2.25rem;
- }
-}
-
-.settings-card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- padding: 1.25rem;
- box-shadow: var(--shadow-sm);
- margin-bottom: 1.5rem;
- width: 100%;
-}
-
-@media (min-width: 768px) {
- .settings-card {
- padding: 1.75rem;
- }
-}
-
-.settings-card-title {
- font-size: 1.05rem;
- font-weight: 700;
- margin-bottom: 0.3rem;
- color: var(--text-main);
-}
-
-.settings-card-subtitle {
- font-size: 0.8rem;
- color: var(--text-muted);
- margin-bottom: 1.25rem;
-}
-
-/* Modals & Overlays */
-.modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.45);
- backdrop-filter: blur(3px);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 100;
- padding: 1rem;
-}
-
-.modal-card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- width: 100%;
- max-width: 520px;
- box-shadow: var(--shadow-lg);
- overflow: hidden;
-}
-
-.modal-header {
- padding: 1rem 1.25rem;
- border-bottom: 1px solid var(--border);
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.modal-header h3 {
- font-size: 1rem;
- font-weight: 700;
-}
-
-.modal-body {
- padding: 1.25rem;
-}
-
-.form-group {
- margin-bottom: 1rem;
-}
-
-.form-label {
- display: block;
- font-size: 0.76rem;
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 0.35rem;
-}
-
-.form-input, .form-select {
- width: 100%;
- background: var(--bg-surface);
- border: 1px solid var(--border-dark);
- border-radius: var(--radius-sm);
- padding: 0.45rem 0.7rem;
- color: var(--text-main);
- font-size: 0.825rem;
- outline: none;
-}
-
-.form-input:focus, .form-select:focus {
- border-color: var(--border-focus);
-}
-
-/* Empty State */
-.empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- height: 100%;
- text-align: center;
- color: var(--text-muted);
- padding: 3rem 1.5rem;
-}
-
-.empty-state-icon {
- font-size: 2.5rem;
- margin-bottom: 0.75rem;
-}
-
-.empty-state h3 {
- font-size: 1.1rem;
- font-weight: 700;
- color: var(--text-main);
- margin-bottom: 0.3rem;
-}
-
-/* Login Screen */
-.login-container {
- height: 100vh;
- width: 100vw;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #f1f5f9;
- padding: 1rem;
-}
-
-.login-card {
- width: 100%;
- max-width: 380px;
- background: #ffffff;
- border: 1px solid var(--border);
- border-radius: var(--radius-md);
- padding: 2.25rem 1.75rem;
- box-shadow: var(--shadow-md);
-}
-
-.login-logo {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.6rem;
- font-size: 1.4rem;
- font-weight: 800;
- margin-bottom: 0.35rem;
-}
-
-.login-subtitle {
- text-align: center;
- font-size: 0.825rem;
- color: var(--text-muted);
- margin-bottom: 1.75rem;
-}
-
-.alert-danger {
- background: #fef2f2;
- border: 1px solid #fecaca;
- color: var(--danger);
- padding: 0.6rem 0.75rem;
- border-radius: var(--radius-sm);
- font-size: 0.8rem;
- margin-bottom: 1.1rem;
-}
-
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-.animate-spin {
- animation: spin 1s linear infinite;
-}
-
+/* RSSPipe - Master Stylesheet Import */
+
+@import './styles/tokens.css';
+@import './styles/base.css';
+@import './styles/three-pane.css';
+@import './styles/waterfall.css';
+@import './styles/settings.css';
+@import './styles/modals.css';
diff --git a/web/src/services/api.ts b/web/src/services/api.ts
index 2094df6..51fc1ce 100644
--- a/web/src/services/api.ts
+++ b/web/src/services/api.ts
@@ -97,10 +97,10 @@ export const api = {
return request('/feeds');
},
- async createFeed(feed_url: string, category_id?: string, title?: string): Promise {
+ async createFeed(feed_url: string, category_id?: string, title?: string, icon_url?: string): Promise {
return request('/feeds', {
method: 'POST',
- body: JSON.stringify({ feed_url, category_id, title }),
+ body: JSON.stringify({ feed_url, category_id, title, icon_url }),
});
},
@@ -109,6 +109,7 @@ export const api = {
data: {
title?: string;
category_id?: string;
+ icon_url?: string;
refresh_interval_minutes?: number;
timeout_seconds?: number;
}
@@ -186,6 +187,18 @@ export const api = {
});
},
+ async toggleShare(id: string): Promise<{ shared: boolean; share_uuid: string | null; entry: FeedEntry }> {
+ return request<{ shared: boolean; share_uuid: string | null; entry: FeedEntry }>(`/entries/${id}/share`, {
+ method: 'POST',
+ });
+ },
+
+ async generateAISummary(id: string): Promise {
+ return request(`/entries/${id}/ai-summary`, {
+ method: 'POST',
+ });
+ },
+
// Rules
async listRules(): Promise {
return request('/rules');
@@ -262,7 +275,28 @@ export const api = {
method: 'POST',
body: JSON.stringify(data || {}),
});
+ },
+
+ // Search History
+ async getSearchHistory(query?: string, limit: number = 10): Promise {
+ const qParam = query ? `?q=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`;
+ return request(`/search/history${qParam}`);
+ },
+
+ async recordSearch(keyword: string): Promise {
+ return request('/search/history', {
+ method: 'POST',
+ body: JSON.stringify({ keyword }),
+ });
+ },
+
+ async deleteSearchHistory(keyword?: string): Promise {
+ const qParam = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '';
+ return request(`/search/history${qParam}`, {
+ method: 'DELETE',
+ });
}
};
+
diff --git a/web/src/styles/base.css b/web/src/styles/base.css
new file mode 100644
index 0000000..4151ff9
--- /dev/null
+++ b/web/src/styles/base.css
@@ -0,0 +1,340 @@
+/* RSSPipe - Base App Styles & Global Utilities */
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ -webkit-tap-highlight-color: transparent;
+}
+
+html, body, #root {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ font-family: var(--font-sans);
+ background-color: var(--bg-app);
+ color: var(--text-primary);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* Custom Minimal Micro-Scrollbars */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+::-webkit-scrollbar-thumb {
+ background: var(--border-strong);
+ border-radius: var(--radius-full);
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* Base App Shell */
+.app-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+ background: var(--bg-app);
+ position: relative;
+}
+
+/* Shared Top Navigation Header */
+.app-header {
+ height: var(--header-height);
+ min-height: var(--header-height);
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1.25rem;
+ z-index: 40;
+ user-select: none;
+ gap: 1rem;
+}
+
+.header-left, .header-right {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ flex-shrink: 0;
+}
+
+.brand-badge {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ font-weight: 800;
+ font-size: 1.05rem;
+ color: var(--text-primary);
+ text-decoration: none;
+ cursor: pointer;
+ letter-spacing: -0.02em;
+}
+
+.brand-icon {
+ width: 22px;
+ height: 22px;
+ color: var(--text-primary);
+}
+
+/* Wide Prominent Search Bar */
+.search-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 380px;
+ max-width: 480px;
+ flex: 0 1 420px;
+}
+
+.search-input {
+ width: 100%;
+ height: 34px;
+ padding: 0 2rem 0 2.2rem;
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ font-size: 0.85rem;
+ color: var(--text-primary);
+ outline: none;
+ transition: var(--transition-fast);
+}
+
+.search-input:focus {
+ border-color: var(--border-focus);
+ background: var(--bg-surface);
+ box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.08);
+}
+
+.search-icon-inside {
+ position: absolute;
+ left: 0.75rem;
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+.search-shortcut-badge {
+ position: absolute;
+ right: 0.6rem;
+ padding: 1px 5px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
+ font-size: 0.7rem;
+ font-family: var(--font-mono);
+ color: var(--text-muted);
+ pointer-events: none;
+}
+
+/* Minimal Action Buttons */
+.btn-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.btn-icon:hover {
+ background: var(--bg-subtle);
+ color: var(--text-primary);
+ border-color: var(--border-strong);
+}
+
+.btn-icon:active {
+ transform: scale(0.96);
+}
+
+.btn-primary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.45rem 0.85rem;
+ height: 32px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+ background: var(--accent-primary);
+ color: var(--text-inverse);
+ border: none;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.45rem 0.85rem;
+ height: 32px;
+ font-size: 0.85rem;
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-subtle);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-subtle);
+ color: var(--text-primary);
+ border-color: var(--border-strong);
+}
+
+/* Discrete View Mode Toggle */
+.discrete-view-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.3rem 0.65rem;
+ height: 32px;
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.discrete-view-toggle:hover {
+ background: var(--bg-subtle);
+ color: var(--text-primary);
+ border-color: var(--border-strong);
+}
+
+/* Spinning Animation for Refresh */
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+
+.spinning {
+ display: inline-block !important;
+ animation: spin 0.8s linear infinite !important;
+ transform-origin: center center !important;
+}
+
+/* Search Autocomplete Dropdown */
+.search-autocomplete-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-md);
+ z-index: 50;
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 0.35rem 0;
+}
+
+.search-autocomplete-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.35rem 0.75rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+}
+
+.search-autocomplete-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.45rem 0.75rem;
+ font-size: 0.82rem;
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: var(--transition-fast);
+ gap: 0.5rem;
+}
+
+.search-autocomplete-item:hover, .search-autocomplete-item.active {
+ background: var(--bg-subtle);
+}
+
+.search-autocomplete-delete-btn {
+ opacity: 0;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ padding: 2px;
+ border-radius: var(--radius-xs);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: var(--transition-fast);
+}
+
+.search-autocomplete-item:hover .search-autocomplete-delete-btn {
+ opacity: 1;
+}
+
+.search-autocomplete-delete-btn:hover {
+ color: var(--danger);
+ background: var(--danger-soft);
+}
+
+/* Badge Utilities */
+.badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 7px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ border-radius: var(--radius-full);
+}
+
+.badge-unread {
+ background: var(--accent-soft);
+ color: var(--accent-primary);
+}
+
+.badge-starred {
+ background: var(--star-bg);
+ color: var(--star-gold);
+}
+
+.badge-ai {
+ background: var(--badge-ai-soft);
+ color: var(--badge-ai);
+}
+
+.badge-tts {
+ background: var(--badge-tts-soft);
+ color: var(--badge-tts);
+}
diff --git a/web/src/styles/modals.css b/web/src/styles/modals.css
new file mode 100644
index 0000000..24560c1
--- /dev/null
+++ b/web/src/styles/modals.css
@@ -0,0 +1,118 @@
+/* RSSPipe - Shared Modal Dialog Styles */
+
+.modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-overlay);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ z-index: 150;
+ animation: modalFadeIn 0.15s ease-out;
+}
+
+@keyframes modalFadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.modal-dialog {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ width: 100%;
+ max-width: 520px;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ animation: modalScaleUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes modalScaleUp {
+ from { transform: scale(0.96); opacity: 0; }
+ to { transform: scale(1); opacity: 1; }
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.15rem 1.25rem;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.modal-title {
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.modal-body {
+ padding: 1.25rem;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding: 1rem 1.25rem;
+ border-top: 1px solid var(--border-subtle);
+ background: var(--bg-subtle);
+}
+
+.btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.45rem 0.85rem;
+ height: 32px;
+ font-size: 0.85rem;
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-subtle);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-subtle);
+ color: var(--text-primary);
+ border-color: var(--border-strong);
+}
+
+/* Shortcut Modal Table */
+.shortcut-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border-light);
+ font-size: 0.85rem;
+}
+
+.kbd-badge {
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
+ padding: 2px 6px;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ box-shadow: var(--shadow-xs);
+}
diff --git a/web/src/styles/settings.css b/web/src/styles/settings.css
new file mode 100644
index 0000000..c079258
--- /dev/null
+++ b/web/src/styles/settings.css
@@ -0,0 +1,203 @@
+/* RSSPipe - High-Precision Metallic Settings Styles */
+
+.settings-container {
+ display: flex;
+ height: calc(100vh - var(--header-height));
+ width: 100vw;
+ background: var(--bg-app);
+ overflow: hidden;
+}
+
+.settings-sidebar {
+ width: 240px;
+ background: var(--bg-sidebar);
+ border-right: 1px solid var(--border-subtle);
+ display: flex;
+ flex-direction: column;
+ padding: 1.25rem 0.75rem;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.settings-nav-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.6rem 0.85rem;
+ border-radius: var(--radius-sm);
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ font-weight: 500;
+ cursor: pointer;
+ width: 100%;
+ text-align: left;
+ transition: var(--transition-fast);
+}
+
+.settings-nav-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+}
+
+.settings-nav-btn.active {
+ background: var(--bg-card-active);
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+/* Fluid Full-Width Settings Content Pane */
+.settings-content-pane {
+ flex: 1;
+ overflow-y: auto;
+ padding: 2.25rem 3.5rem;
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.settings-header-banner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 2rem;
+ padding-bottom: 1.25rem;
+ border-bottom: 1px solid var(--border-subtle);
+}
+
+.settings-section-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 1.5rem 1.75rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--shadow-xs);
+}
+
+.settings-section-title {
+ font-size: 1.2rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: var(--text-primary);
+ margin-bottom: 0.35rem;
+}
+
+.settings-section-desc {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 1.5rem;
+}
+
+.settings-form-row {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ margin-bottom: 1.15rem;
+}
+
+.settings-label {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.settings-input, .settings-select, .settings-textarea {
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ font-size: 0.85rem;
+ font-family: inherit;
+ outline: none;
+ transition: var(--transition-fast);
+}
+
+.settings-input:focus, .settings-select:focus, .settings-textarea:focus {
+ border-color: var(--border-focus);
+ box-shadow: 0 0 0 2px rgba(24, 24, 27, 0.08);
+}
+
+/* Feed Management List */
+.settings-feed-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid var(--border-light);
+ gap: 1rem;
+}
+
+.settings-feed-item:last-child {
+ border-bottom: none;
+}
+
+/* Prompt Template Card */
+.prompt-template-card {
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ padding: 1rem;
+ background: var(--bg-card);
+ margin-bottom: 0.75rem;
+ transition: var(--transition-fast);
+}
+
+.prompt-template-card:hover {
+ border-color: var(--border-strong);
+}
+
+/* Stats Metric Grid */
+.stats-grid-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.stats-metric-card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 1.15rem 1.25rem;
+ box-shadow: var(--shadow-xs);
+}
+
+.stats-metric-value {
+ font-size: 1.85rem;
+ font-weight: 800;
+ color: var(--text-primary);
+ line-height: 1;
+ margin-bottom: 0.35rem;
+ letter-spacing: -0.02em;
+}
+
+.stats-metric-label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-muted);
+}
+
+/* Mobile adaptation for settings */
+@media (max-width: 768px) {
+ .settings-container {
+ flex-direction: column;
+ padding-bottom: var(--bottom-nav-height);
+ }
+ .settings-sidebar {
+ width: 100%;
+ flex-direction: row;
+ overflow-x: auto;
+ border-right: none;
+ border-bottom: 1px solid var(--border-subtle);
+ padding: 0.5rem;
+ }
+ .settings-nav-btn {
+ white-space: nowrap;
+ width: auto;
+ }
+ .settings-content-pane {
+ padding: 1.25rem 1rem;
+ }
+}
diff --git a/web/src/styles/three-pane.css b/web/src/styles/three-pane.css
new file mode 100644
index 0000000..1b71733
--- /dev/null
+++ b/web/src/styles/three-pane.css
@@ -0,0 +1,841 @@
+/* RSSPipe - Desktop 3-Split Workspace Styles */
+
+.three-pane-container {
+ display: flex;
+ height: calc(100vh - var(--header-height));
+ width: 100vw;
+ overflow: hidden;
+ position: relative;
+ background: var(--bg-app);
+}
+
+/* Splitter Gutter Handle */
+.pane-splitter {
+ width: 6px;
+ background: transparent;
+ cursor: col-resize;
+ position: relative;
+ z-index: 20;
+ transition: background var(--transition-fast);
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.pane-splitter::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 2px;
+ width: 1px;
+ background: var(--border-subtle);
+ transition: background var(--transition-fast);
+}
+
+.pane-splitter:hover::after,
+.pane-splitter.is-dragging::after {
+ background: var(--accent-primary);
+ width: 2px;
+ left: 2px;
+}
+
+.pane-splitter:hover,
+.pane-splitter.is-dragging {
+ background: var(--accent-soft);
+}
+
+/* Global drag overlay to prevent pointer events on iframes/text selection */
+.splitter-drag-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 9999;
+ cursor: col-resize;
+ user-select: none;
+}
+
+/* Pane 1: Sidebar */
+.desktop-sidebar {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--bg-sidebar);
+ border-right: 1px solid var(--border-subtle);
+ overflow: hidden;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.desktop-sidebar.collapsed {
+ width: 0 !important;
+ min-width: 0 !important;
+ border-right: none;
+ display: none;
+}
+
+.sidebar-section-title {
+ padding: 0.75rem 1rem 0.35rem;
+ font-size: 0.7rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+}
+
+.sidebar-nav-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ padding: 0 0.5rem;
+}
+
+.sidebar-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.35rem 0.55rem;
+ min-height: 28px;
+ border-radius: var(--radius-sm);
+ color: var(--text-secondary);
+ font-size: 0.83rem;
+ cursor: pointer;
+ border: none;
+ background: transparent;
+ width: 100%;
+ text-align: left;
+ transition: var(--transition-fast);
+}
+
+.sidebar-item:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+}
+
+.sidebar-item.active {
+ background: var(--bg-card-active);
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.sidebar-item-left {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sidebar-item-icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+.sidebar-item-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sidebar-scroll-area {
+ flex: 1;
+ overflow-y: auto;
+ padding-bottom: 1rem;
+}
+
+.sidebar-category-group {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.sidebar-category-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.45rem 0.6rem;
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ border: none;
+ background: transparent;
+ width: 100%;
+ text-align: left;
+ transition: var(--transition-fast);
+}
+
+.sidebar-category-header:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+}
+
+.category-chevron-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: var(--radius-xs);
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.category-chevron-btn:hover {
+ color: var(--text-primary);
+ background: var(--bg-card-hover);
+}
+
+.sidebar-category-children {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+/* Pane 2: Article List Column */
+.desktop-list-pane {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: var(--bg-surface);
+ border-right: 1px solid var(--border-subtle);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.list-header-bar {
+ padding: 0.6rem 0.85rem;
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--bg-surface);
+ gap: 0.5rem;
+}
+
+.list-title-group {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+}
+
+.list-title-text {
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.list-scope-pill-group {
+ display: flex;
+ align-items: center;
+ background: var(--bg-subtle);
+ border-radius: var(--radius-sm);
+ padding: 2px;
+ border: 1px solid var(--border-subtle);
+}
+
+.scope-pill-btn {
+ padding: 2px 7px;
+ font-size: 0.72rem;
+ font-weight: 600;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ border-radius: var(--radius-xs);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.scope-pill-btn.active {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ box-shadow: var(--shadow-xs);
+}
+
+.list-mode-toggle {
+ display: flex;
+ align-items: center;
+ background: var(--bg-subtle);
+ border-radius: var(--radius-sm);
+ padding: 2px;
+ border: 1px solid var(--border-subtle);
+}
+
+.list-mode-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 24px;
+ border-radius: var(--radius-xs);
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.list-mode-btn.active {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ box-shadow: var(--shadow-xs);
+}
+
+.desktop-articles-scroll {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Compact Article Row (2-Line Minimal) */
+.article-row-compact {
+ display: flex;
+ align-items: center;
+ padding: 0.65rem 0.85rem;
+ border-bottom: 1px solid var(--border-light);
+ cursor: pointer;
+ gap: 0.6rem;
+ transition: var(--transition-fast);
+ background: var(--bg-surface);
+ user-select: none;
+}
+
+.article-row-compact:hover {
+ background: var(--bg-card-hover);
+}
+
+.article-row-compact.selected {
+ background: var(--bg-card-active);
+}
+
+.article-row-compact.unread .compact-title {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.unread-indicator-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: var(--radius-full);
+ background: var(--accent-primary);
+ flex-shrink: 0;
+}
+
+.unread-indicator-dot.read {
+ background: transparent;
+}
+
+.compact-content-col {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.compact-title {
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.35;
+}
+
+.compact-date {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+}
+
+.compact-action-col {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.compact-icon-item,
+.compact-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: var(--radius-xs);
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.compact-icon-btn:hover {
+ background: var(--bg-card-hover);
+ color: var(--text-primary);
+}
+
+/* Non-Compact (Expanded) Article Card Mode */
+.article-card-rich {
+ padding: 0.85rem 1rem;
+ border-bottom: 1px solid var(--border-light);
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ background: var(--bg-surface);
+ transition: var(--transition-fast);
+ user-select: none;
+}
+
+.article-card-rich:hover {
+ background: var(--bg-card-hover);
+}
+
+.article-card-rich.selected {
+ background: var(--bg-card-active);
+}
+
+.article-card-rich .card-title {
+ font-size: 0.92rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ line-height: 1.38;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.article-card-rich.unread .card-title {
+ font-weight: 700;
+}
+
+.article-card-rich .card-snippet {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ line-height: 1.45;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.card-meta-status-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ margin-top: 0.1rem;
+}
+
+.card-action-icons {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.card-chips-row {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+ margin-top: 0.15rem;
+}
+
+.card-chip {
+ padding: 1px 6px;
+ border-radius: var(--radius-xs);
+ font-size: 0.68rem;
+ font-weight: 500;
+}
+
+.card-keyword-chip {
+ background: rgba(59, 130, 246, 0.08);
+ color: #2563eb;
+ border: 1px solid rgba(59, 130, 246, 0.2);
+}
+
+.card-tag-chip {
+ background: rgba(16, 185, 129, 0.08);
+ color: #059669;
+ border: 1px solid rgba(16, 185, 129, 0.2);
+}
+
+/* Pane 3: Detail Reader Pane */
+.desktop-reader-pane {
+ flex: 1;
+ height: 100%;
+ background: var(--bg-surface);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.reader-toolbar {
+ height: 48px;
+ min-height: 48px;
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1.25rem;
+ background: var(--bg-surface);
+ gap: 0.75rem;
+ z-index: 10;
+}
+
+.reader-toolbar-left, .reader-toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Split Action Button & Dropdown in Reader Toolbar */
+.split-action-container {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ border-radius: var(--radius-sm);
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ box-shadow: var(--shadow-xs);
+}
+
+.split-action-main-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.35rem 0.65rem;
+ height: 30px;
+ background: transparent;
+ border: none;
+ border-right: 1px solid var(--border-subtle);
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ cursor: pointer;
+ border-top-left-radius: var(--radius-sm);
+ border-bottom-left-radius: var(--radius-sm);
+ transition: var(--transition-fast);
+}
+
+.split-action-main-btn:hover {
+ background: var(--bg-subtle);
+}
+
+.split-action-arrow-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 30px;
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-top-right-radius: var(--radius-sm);
+ border-bottom-right-radius: var(--radius-sm);
+ transition: var(--transition-fast);
+}
+
+.split-action-arrow-btn:hover {
+ background: var(--bg-subtle);
+ color: var(--text-primary);
+}
+
+.split-dropdown-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ width: 210px;
+ background: var(--bg-surface);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+ padding: 0.3rem 0;
+ z-index: 50;
+ display: flex;
+ flex-direction: column;
+}
+
+.split-dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.82rem;
+ color: var(--text-primary);
+ background: transparent;
+ border: none;
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.split-dropdown-item:hover {
+ background: var(--bg-subtle);
+}
+
+/* Reader Scroll Area (Far Right Scrollbar) */
+.reader-scroll-area {
+ flex: 1;
+ width: 100%;
+ height: 100%;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.reader-content-inner {
+ width: 100%;
+ max-width: 860px;
+ padding: 2.5rem 3rem;
+}
+
+.reader-header {
+ margin-bottom: 1.5rem;
+}
+
+.reader-feed-subtitle {
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+ margin-bottom: 0.4rem;
+}
+
+.reader-article-title {
+ font-size: 1.85rem;
+ font-weight: 800;
+ line-height: 1.28;
+ color: var(--text-primary);
+ margin-bottom: 0.85rem;
+ letter-spacing: -0.02em;
+}
+
+.reader-meta-line {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.83rem;
+ color: var(--text-muted);
+ margin-bottom: 0.45rem;
+}
+
+.reader-meta-icon {
+ color: var(--text-muted);
+ flex-shrink: 0;
+}
+
+.reader-author-name {
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.reader-meta-dot {
+ color: var(--border-strong);
+}
+
+/* Keywords and Tags Chips */
+.reader-chips-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.reader-chips-label {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.reader-chips-list {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
+.reader-chip {
+ padding: 2px 7px;
+ border-radius: var(--radius-xs);
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-subtle);
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.reader-tag-chip {
+ background: rgba(24, 24, 27, 0.04);
+ color: var(--accent-primary);
+ font-weight: 600;
+}
+
+.reader-header-divider {
+ border: none;
+ border-top: 1px solid var(--border-subtle);
+ margin: 1.5rem 0 2rem;
+ width: 100%;
+}
+
+/* AI Summary Box in Desktop Reader */
+.desktop-ai-summary-box {
+ background: var(--badge-ai-soft);
+ border: 1px solid rgba(79, 70, 229, 0.2);
+ border-radius: var(--radius-md);
+ padding: 1rem 1.25rem;
+ margin-bottom: 2rem;
+}
+
+.desktop-ai-summary-box.ai-error-box {
+ background: var(--danger-soft, rgba(239, 68, 68, 0.08));
+ border: 1px solid rgba(239, 68, 68, 0.25);
+}
+
+.ai-summary-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ font-weight: 700;
+ color: var(--badge-ai);
+ margin-bottom: 0.6rem;
+}
+
+.ai-summary-body {
+ font-size: 0.9rem;
+ line-height: 1.6;
+ color: var(--text-secondary);
+}
+
+/* Audio Player Bar in Desktop Reader */
+.desktop-inline-audio-bar {
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 0.75rem 1rem;
+ margin-bottom: 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.audio-play-circle-btn {
+ width: 38px;
+ height: 38px;
+ border-radius: var(--radius-full);
+ background: var(--accent-primary);
+ color: var(--text-inverse);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: var(--transition-fast);
+}
+
+.audio-play-circle-btn:hover {
+ background: var(--accent-hover);
+ transform: scale(1.04);
+}
+
+/* Reader Typography Body */
+.reader-body-rendered {
+ font-size: 1.05rem;
+ line-height: 1.75;
+ color: var(--text-secondary);
+}
+
+.reader-body-rendered p {
+ margin-bottom: 1.35rem;
+}
+
+.reader-body-rendered h2 {
+ font-size: 1.4rem;
+ font-weight: 700;
+ margin: 2rem 0 1rem;
+ color: var(--text-primary);
+}
+
+.reader-body-rendered h3 {
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin: 1.5rem 0 0.75rem;
+ color: var(--text-primary);
+}
+
+.reader-body-rendered img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--radius-md);
+ margin: 1.5rem 0;
+}
+
+.reader-body-rendered blockquote {
+ border-left: 3px solid var(--border-strong);
+ padding-left: 1rem;
+ margin: 1.5rem 0;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.reader-body-rendered code {
+ background: var(--bg-subtle);
+ padding: 2px 6px;
+ border-radius: var(--radius-xs);
+ font-family: var(--font-mono);
+ font-size: 0.85em;
+}
+
+.reader-body-rendered pre {
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+ overflow-x: auto;
+ margin: 1.5rem 0;
+}
+
+/* Reader Empty State */
+.reader-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-muted);
+ gap: 1rem;
+ padding: 2rem;
+ text-align: center;
+}
+
+.reader-empty-icon {
+ width: 48px;
+ height: 48px;
+ color: var(--text-dim);
+}
diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css
new file mode 100644
index 0000000..ebe97b8
--- /dev/null
+++ b/web/src/styles/tokens.css
@@ -0,0 +1,79 @@
+/* RSSPipe - High-Precision Metallic Monochrome Design Tokens */
+
+:root {
+ /* Surfaces & Backgrounds (Crisp Titanium & Neutral Monochrome) */
+ --bg-app: #f4f4f5;
+ --bg-surface: #ffffff;
+ --bg-sidebar: #fafafa;
+ --bg-card: #ffffff;
+ --bg-card-hover: #f4f4f5;
+ --bg-card-active: #e4e4e7;
+ --bg-subtle: #f4f4f5;
+ --bg-overlay: rgba(9, 9, 11, 0.45);
+ --bg-docked: rgba(255, 255, 255, 0.96);
+
+ /* Borders (Neutral Steel / Zinc) */
+ --border-light: #f4f4f5;
+ --border-subtle: #e4e4e7;
+ --border-strong: #d4d4d8;
+ --border-focus: #18181b;
+
+ /* Typography */
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: 'JetBrains Mono', monospace;
+
+ /* Text Colors (Pure High-Contrast Zinc) */
+ --text-primary: #09090b;
+ --text-secondary: #27272a;
+ --text-muted: #71717a;
+ --text-dim: #a1a1aa;
+ --text-inverse: #ffffff;
+
+ /* Brand Accents (Titanium Black & Steel) */
+ --accent-primary: #18181b;
+ --accent-hover: #27272a;
+ --accent-soft: rgba(24, 24, 27, 0.06);
+ --accent-border: #d4d4d8;
+
+ /* Semantic Highlight Colors */
+ --star-gold: #d97706;
+ --star-bg: #fef3c7;
+ --success: #059669;
+ --success-soft: #d1fae5;
+ --danger: #dc2626;
+ --danger-soft: #fee2e2;
+ --badge-ai: #4f46e5;
+ --badge-ai-soft: #e0e7ff;
+ --badge-tts: #0284c7;
+ --badge-tts-soft: #e0f2fe;
+
+ /* Radiuses */
+ --radius-xs: 4px;
+ --radius-sm: 6px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 20px;
+ --radius-full: 9999px;
+
+ /* Shadows (Subtle, Crisp Elevation) */
+ --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
+ --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12);
+ --shadow-docked: 0 -4px 20px rgba(0, 0, 0, 0.07);
+
+ /* Layout Dimensions */
+ --header-height: 50px;
+ --bottom-nav-height: 58px;
+ --docked-audio-height: 56px;
+ --sidebar-min-width: 200px;
+ --sidebar-default-width: 250px;
+ --list-min-width: 300px;
+ --list-default-width: 400px;
+ --reader-min-width: 480px;
+
+ /* Transitions */
+ --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
+ --transition-smooth: 350ms cubic-bezier(0.32, 0.72, 0, 1);
+}
diff --git a/web/src/styles/waterfall.css b/web/src/styles/waterfall.css
new file mode 100644
index 0000000..5d09f59
--- /dev/null
+++ b/web/src/styles/waterfall.css
@@ -0,0 +1,427 @@
+/* RSSPipe - Mobile Waterfall & iOS Native Experience Styles */
+
+.waterfall-app-root {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+ background: var(--bg-app);
+ position: relative;
+}
+
+/* Mobile Header */
+.mobile-top-header {
+ height: 48px;
+ min-height: 48px;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1rem;
+ z-index: 30;
+ user-select: none;
+}
+
+.mobile-header-title {
+ font-size: 1.05rem;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: var(--text-primary);
+}
+
+/* Mobile Category Pill Tabs */
+.mobile-tabs-pill-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.5rem 0.85rem;
+ background: var(--bg-surface);
+ border-bottom: 1px solid var(--border-light);
+ overflow-x: auto;
+ scrollbar-width: none;
+ -webkit-overflow-scrolling: touch;
+ flex-shrink: 0;
+ z-index: 25;
+}
+
+.mobile-tabs-pill-bar::-webkit-scrollbar {
+ display: none;
+}
+
+.mobile-tab-pill {
+ padding: 0.35rem 0.75rem;
+ border-radius: var(--radius-full);
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: var(--text-muted);
+ background: var(--bg-subtle);
+ border: 1px solid transparent;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.mobile-tab-pill.active {
+ background: var(--accent-primary);
+ color: var(--text-inverse);
+ font-weight: 600;
+}
+
+/* Waterfall Scroll Area */
+.waterfall-scroll-area {
+ flex: 1;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding: 0.65rem;
+ padding-bottom: calc(var(--bottom-nav-height) + var(--docked-audio-height) + 1rem);
+}
+
+/* 2-Column Waterfall Masonry Grid */
+.waterfall-masonry-grid {
+ display: flex;
+ gap: 0.65rem;
+ align-items: flex-start;
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.waterfall-column {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+ min-width: 0;
+}
+
+/* Xiaohongshu-Style Post Card */
+.xhs-card {
+ background: var(--bg-surface);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ border: 1px solid var(--border-subtle);
+ box-shadow: var(--shadow-xs);
+ display: flex;
+ flex-direction: column;
+ cursor: pointer;
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
+ user-select: none;
+}
+
+.xhs-card:active {
+ transform: scale(0.985);
+}
+
+.xhs-card.read {
+ opacity: 0.82;
+}
+
+.xhs-card-image-wrap {
+ width: 100%;
+ position: relative;
+ overflow: hidden;
+ background: var(--bg-subtle);
+}
+
+.xhs-card-image {
+ width: 100%;
+ height: auto;
+ max-height: 280px;
+ object-fit: cover;
+ display: block;
+}
+
+/* SmartArt Card (For posts without images) */
+.smart-art-cover {
+ width: 100%;
+ min-height: 120px;
+ padding: 1.15rem 1rem;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ position: relative;
+ overflow: hidden;
+}
+
+.smart-art-quote {
+ font-size: 0.95rem;
+ font-weight: 700;
+ line-height: 1.35;
+ color: var(--text-primary);
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ letter-spacing: -0.01em;
+}
+
+.smart-art-accent-dot {
+ width: 24px;
+ height: 3px;
+ border-radius: var(--radius-full);
+ margin-top: 0.75rem;
+}
+
+/* Card Content Details */
+.xhs-card-body {
+ padding: 0.65rem 0.75rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.xhs-card-title {
+ font-size: 0.88rem;
+ font-weight: 600;
+ line-height: 1.35;
+ color: var(--text-primary);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.xhs-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-top: 0.25rem;
+ font-size: 0.72rem;
+ color: var(--text-muted);
+}
+
+.xhs-card-author-group {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.xhs-card-feed-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+
+/* iOS Bottom Navigation Bar */
+.ios-bottom-nav {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: var(--bottom-nav-height);
+ background: var(--bg-docked);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-top: 1px solid var(--border-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ padding: 0 0.5rem;
+ z-index: 50;
+ user-select: none;
+}
+
+.bottom-nav-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ flex: 1;
+ height: 100%;
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ font-size: 0.68rem;
+ font-weight: 500;
+ cursor: pointer;
+ position: relative;
+ transition: var(--transition-fast);
+}
+
+.bottom-nav-item.active {
+ color: var(--accent-primary);
+ font-weight: 700;
+}
+
+.bottom-nav-icon {
+ width: 20px;
+ height: 20px;
+}
+
+.bottom-nav-badge {
+ position: absolute;
+ top: 6px;
+ right: calc(50% - 16px);
+ background: var(--danger);
+ color: var(--text-inverse);
+ font-size: 0.6rem;
+ font-weight: 700;
+ padding: 1px 4px;
+ border-radius: var(--radius-full);
+ min-width: 14px;
+ text-align: center;
+}
+
+/* Mobile Slide-In Full-Screen Article Sheet */
+.mobile-article-sheet {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-surface);
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: transform var(--transition-smooth);
+}
+
+.mobile-article-sheet.slide-in {
+ animation: sheetSlideIn 0.3s cubic-bezier(0.32, 0.72, 0, 1) forwards;
+}
+
+@keyframes sheetSlideIn {
+ from { transform: translateY(100%); }
+ to { transform: translateY(0); }
+}
+
+.mobile-sheet-header {
+ height: 48px;
+ min-height: 48px;
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 0.75rem;
+ background: var(--bg-surface);
+ z-index: 10;
+}
+
+.mobile-sheet-scroll {
+ flex: 1;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding: 1.25rem 1.15rem 4rem;
+}
+
+/* Docked Mini Audio Player */
+.docked-audio-player {
+ position: fixed;
+ left: 0.75rem;
+ right: 0.75rem;
+ bottom: calc(var(--bottom-nav-height) + 0.5rem);
+ height: var(--docked-audio-height);
+ background: var(--bg-docked);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-docked);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 0.85rem;
+ z-index: 45;
+ cursor: pointer;
+ transition: transform var(--transition-fast);
+}
+
+.docked-audio-player:active {
+ transform: scale(0.99);
+}
+
+.docked-progress-line {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 2.5px;
+ background: var(--badge-tts);
+ border-radius: var(--radius-full);
+}
+
+.docked-audio-info {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ min-width: 0;
+ flex: 1;
+}
+
+.docked-audio-title {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.docked-audio-controls {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-shrink: 0;
+}
+
+/* Expanded Full Player Modal */
+.audio-expanded-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: var(--bg-overlay);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ z-index: 200;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+}
+
+.audio-expanded-card {
+ background: var(--bg-surface);
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+ padding: 1.5rem 1.5rem 2.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ max-width: 500px;
+ width: 100%;
+ margin: 0 auto;
+}
+
+.audio-scrubber-slider {
+ width: 100%;
+ accent-color: var(--accent-primary);
+ cursor: pointer;
+}
+
+.audio-speed-chips {
+ display: flex;
+ justify-content: center;
+ gap: 0.5rem;
+}
+
+.audio-speed-chip {
+ padding: 0.25rem 0.6rem;
+ border-radius: var(--radius-sm);
+ background: var(--bg-subtle);
+ border: 1px solid var(--border-subtle);
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.audio-speed-chip.active {
+ background: var(--accent-primary);
+ color: var(--text-inverse);
+ border-color: var(--accent-primary);
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index 92ec16c..d99a852 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -18,6 +18,7 @@ export interface Feed {
title: string;
feed_url: string;
site_url?: string;
+ icon_url?: string;
category_id?: string;
category_title?: string;
is_virtual_digest: boolean;
@@ -91,12 +92,14 @@ export interface Stats {
total_feeds: number;
total_unread: number;
total_starred: number;
+ total_shared?: number;
total_categories: number;
total_rules?: number;
}
-export type ViewType = 'inbox' | 'starred' | 'all';
+export type ViewType = 'inbox' | 'starred' | 'all' | 'shared';
export type ViewMode = 'split' | 'waterfall' | 'settings';
+export type ViewPreference = 'auto' | 'mobile' | 'desktop';
export interface FilterState {
view: ViewType;
@@ -131,3 +134,10 @@ export interface AITestResult {
model: string;
}
+export interface SearchHistoryItem {
+ keyword: string;
+ last_searched_at: string;
+ search_count: number;
+}
+
+
diff --git a/web/src/utils.ts b/web/src/utils.ts
index 31e28c9..87a207c 100644
--- a/web/src/utils.ts
+++ b/web/src/utils.ts
@@ -51,6 +51,42 @@ export function formatRelativeDate(dateStr: string): string {
}
}
+/**
+ * Format exact date & time as YYYY-MM-DD HH:mm
+ */
+export function formatDateTime(dateStr: string): string {
+ try {
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return dateStr;
+ const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
+ const year = d.getFullYear();
+ const month = pad(d.getMonth() + 1);
+ const day = pad(d.getDate());
+ const hours = pad(d.getHours());
+ const mins = pad(d.getMinutes());
+ return `${year}-${month}-${day} ${hours}:${mins}`;
+ } catch {
+ return dateStr;
+ }
+}
+
+/**
+ * Format exact date as YYYY-MM-DD
+ */
+export function formatDateOnly(dateStr: string): string {
+ try {
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return dateStr;
+ const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
+ const year = d.getFullYear();
+ const month = pad(d.getMonth() + 1);
+ const day = pad(d.getDate());
+ return `${year}-${month}-${day}`;
+ } catch {
+ return dateStr;
+ }
+}
+
/**
* Estimate reading time in minutes
*/
@@ -76,6 +112,28 @@ export function getEntryTags(variants?: Record)
.filter(Boolean);
}
+/**
+ * Generate elegant SmartArt background palettes based on a text seed
+ */
+const SMART_ART_PALETTES = [
+ { bg: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)', accent: '#475569', text: '#0f172a' },
+ { bg: 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)', accent: '#2563eb', text: '#1e3a8a' },
+ { bg: 'linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%)', accent: '#7c3aed', text: '#4c1d95' },
+ { bg: 'linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%)', accent: '#059669', text: '#064e3b' },
+ { bg: 'linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%)', accent: '#ea580c', text: '#7c2d12' },
+ { bg: 'linear-gradient(135deg, #fdf2f8 0%, #fce7f3 100%)', accent: '#db2777', text: '#831843' },
+ { bg: 'linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%)', accent: '#0d9488', text: '#134e4a' },
+];
+
+export function getSmartArtTheme(seed: string): { bg: string; accent: string; text: string } {
+ let hash = 0;
+ for (let i = 0; i < seed.length; i++) {
+ hash = seed.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const index = Math.abs(hash) % SMART_ART_PALETTES.length;
+ return SMART_ART_PALETTES[index];
+}
+
export function renderMarkdownText(markdown?: string): string {
if (!markdown) return '';
@@ -103,7 +161,7 @@ export function renderMarkdownText(markdown?: string): string {
let content = bulletMatch[1];
// Format bold and code
content = content
- .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`(.+?)`/g, '$1');
output.push(`${content}`);
} else {
@@ -112,7 +170,7 @@ export function renderMarkdownText(markdown?: string): string {
inList = false;
}
let content = trimmed
- .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`(.+?)`/g, '$1');
output.push(`${content}
`);
}
@@ -124,4 +182,3 @@ export function renderMarkdownText(markdown?: string): string {
return output.join('');
}
-