feat: scaffold initial RSS reader project with Go backend and React frontend
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# Binaries & build artifacts
|
||||
/bin/
|
||||
/dist/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Local data & SQLite
|
||||
/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Frontend
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.vite/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# PII files
|
||||
sample_subscriptions.opml
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/api"
|
||||
"github.com/haopengzhan/rsspipe/internal/auth"
|
||||
"github.com/haopengzhan/rsspipe/internal/config"
|
||||
"github.com/haopengzhan/rsspipe/internal/crawler"
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Println("==================================================")
|
||||
log.Println(" Starting RSSPipe Server ")
|
||||
log.Println("==================================================")
|
||||
|
||||
cfg := config.LoadConfig()
|
||||
|
||||
// 1. Initialize SQLite Database
|
||||
database, err := db.InitDB(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Fatal: Database initialization failed: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
log.Printf("==> Database connected: %s (WAL mode enabled)\n", cfg.DBPath)
|
||||
|
||||
// 2. Initialize Auth Service & Bootstrap Admin
|
||||
authService := auth.NewAuthService(database, cfg)
|
||||
if err := authService.BootstrapAdmin(); err != nil {
|
||||
log.Fatalf("Fatal: Admin bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Start Background Crawler Pool
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
cr := crawler.NewCrawler(database, cfg.CrawlerIntervalMinutes, cfg.CrawlerWorkers)
|
||||
cr.Start(ctx)
|
||||
|
||||
// 4. Mount API Routes
|
||||
handler := api.NewHandler(database, authService, cr)
|
||||
router := api.NewRouter(handler, authService, "./web/dist")
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 5. Graceful shutdown handler
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
log.Println("\n==> Shutting down RSSPipe gracefully...")
|
||||
cancel()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("Error during server shutdown: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("==> RSSPipe HTTP Server listening at http://localhost:%s\n", cfg.Port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Fatal: Server error: %v", err)
|
||||
}
|
||||
log.Println("==> RSSPipe Server stopped.")
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
|
||||
# 架构设计与需求文档:RSSPipe
|
||||
|
||||
1. 核心需求概述
|
||||
本系统旨在构建一个针对单用户场景的 高度可扩展、本地优先(Local-First)且端到端加密的 RSS 网关客户端(PWA)。它不仅具备传统 RSS 的订阅与阅读功能,更通过事件驱动的规则引擎,深度集成 AI 能力。
|
||||
关键特性要求:
|
||||
|
||||
* 本地优先 (Local-First PWA):核心数据存储与业务流转在客户端(浏览器环境)完成,支持离线访问。
|
||||
* 端到端加密 (E2EE):数据在落盘或同步前必须在客户端加密,保证绝对隐私。
|
||||
* 事件驱动的规则引擎 (Rules Engine):支持类似 Gmail Filter 的 Trigger-Condition-Action 机制。
|
||||
* 防死循环与状态隔离 (DAG & CQRS):严格拆分 Feed 生命周期,分离单实体(Single-Entity)富化逻辑与多实体(Multi-Entity)聚合逻辑,彻底杜绝规则循环执行。
|
||||
* 无缝旁路扩展 (Plugin/RPC):通过定义统一的“增量 Patch”通信契约,支持用 TS 编写本地 Plugin,或通过 gRPC/Connect 调用外部 Go/Python 的 AI 节点。
|
||||
* 无预设动态 Schema:Feed 的附加数据(如 AI 摘要、评分、TTS 音频等)不硬编码在数据模型中,而是通过 JSON 增量合并。
|
||||
* 遥测与反馈循环 (Telemetry):采集阅读行为(驻留时长、可见字数比),为下游 AI 模型或动态权重系统提供微调依据。
|
||||
|
||||
1. 核心架构视图
|
||||
2. 核心数据模型 (Data Schema)
|
||||
系统采用 Core (不可变主键) + Metadata (可变 JSONB) 的模式。
|
||||
// 核心路由与索引字段(关系型特征)
|
||||
interface FeedCore {
|
||||
id: string; // 唯一标识符,基于 url + timestamp 哈希
|
||||
url: string; // 原始链接
|
||||
sourceId: string; // 订阅源 ID
|
||||
timestamp: number; // 抓取/发布时间戳
|
||||
title: string;
|
||||
content: string; // 清洗后的纯文本/Markdown
|
||||
}
|
||||
|
||||
// 变长扩展数据(无预设 Schema)
|
||||
interface FeedMetadata {
|
||||
isRead: boolean;
|
||||
tags: string[]; // 标签集合
|
||||
aiSummary?: string; // AI 摘要
|
||||
ttsUrl?: string; // 语音合成链接
|
||||
score?: number; // 推荐分数
|
||||
[key: string]: any; // 其他外部 Plugin 注入的动态字段
|
||||
}
|
||||
|
||||
// 数据库实际存储结构(明文态)
|
||||
interface LocalFeedEntry extends FeedCore {
|
||||
metadata: FeedMetadata;
|
||||
}
|
||||
|
||||
1. 生命周期与规则引擎设计 (DAG Rule Engine)
|
||||
为了防止死循环,Feed 的处理被严格限制在一个单向流动的流水线中。
|
||||
4.1 生命周期的四个阶段 (Phases)
|
||||
|
||||
* Ingestion (摄入期):从远程 XML 拉取数据,使用 rss-parser 解析,使用 @mozilla/readability 提取纯正文。
|
||||
* Enrichment (丰富期):单 Feed 事件的主战场。在此阶段,系统调用配置好的 Plugin(本地或远程),获取摘要、打分等元数据。
|
||||
* Aggregation (聚合期):批处理的主战场。由独立的调度器(基于 RxJS)监听数据库写入操作,当符合特定条件的 Feed 数量达到阈值,触发聚合 Action(如生成周报)。
|
||||
* Action (动作期):数据的最终落盘加密存储,以及触发 UI 层的渲染与系统通知。
|
||||
4.2 统一 Plugin 接口契约 (Plugin Interface Protocol)
|
||||
无论是前端本地的 TypeScript 函数,还是远端的 Go 语言 gRPC 服务,都必须遵循以下契约进行通信,强制使用 JSON Patch,禁止外部服务直接覆写完整 Feed 数据。
|
||||
// 输入契约 (发送给 Plugin 的数据)
|
||||
interface PluginInput {
|
||||
eventId: string;
|
||||
phase: "enrichment" | "aggregation";
|
||||
feedSnapshot: Readonly<FeedCore>;
|
||||
feedMetadata: Readonly<FeedMetadata>;
|
||||
triggerReason: string;
|
||||
}
|
||||
|
||||
// 输出契约 (Plugin 返回给主系统的指令)
|
||||
interface PluginOutput {
|
||||
action: "merge_metadata" | "drop_feed" | "none";
|
||||
metadataPatch?: {
|
||||
$set?: Record<string, any>; // 覆写字段,如 { aiSummary: "..." }
|
||||
$push?: Record<string, any[]>; // 追加数组元素,如 { tags: ["tech"] }
|
||||
};
|
||||
}
|
||||
|
||||
4.3 规则防死锁机制
|
||||
在添加或修改 Rule 时,运行拓扑排序(Topological Sort)引擎,验证 Trigger 和 Action 之间是否存在循环依赖,存在则拒绝保存配置。
|
||||
5. 里程碑拆解与模块化实现方案
|
||||
Milestone 1: 本地基础设施 (Local-First Base)
|
||||
|
||||
* 任务目标:搭建基础 PWA 框架,实现端到端加密与基础数据读写。
|
||||
* 关键技术栈:Vite + React, Dexie.js (IndexedDB), Web Crypto API。
|
||||
* 核心模块:
|
||||
* DB 层:基于 Dexie.js 定义本地表结构,只允许应用层通过统一的 Service 读写。
|
||||
* 加密拦截器:数据写入 IndexedDB 前进行 AES-GCM 加密,读取时解密,确保本地离线数据的安全性。
|
||||
* 基础 Parser:实现对 RSS 源的拉取和 @mozilla/readability 的正文清洗。
|
||||
Milestone 2: 单 Feed 规则引擎与本地 Plugin (Core Engine)
|
||||
* 任务目标:实现生命周期流水线与 Enrichment 阶段的核心逻辑。
|
||||
* 关键技术栈:RxJS。
|
||||
* 核心模块:
|
||||
* 生命周期 Event Bus:基于 RxJS Subject 创建事件总线,控制数据流从 Ingestion 向下流转。
|
||||
* Plugin 调度中心:根据用户配置的 Rules,将数据 Snapshot 传递给对应的本地 TS 函数,获取 Patch 并执行安全合并(Lodash merge 等效逻辑)。
|
||||
* DAG 校验器:实现并集成拓扑排序算法,验证用户配置。
|
||||
Milestone 3: 旁路扩展层 (Remote RPC & Extensibility)
|
||||
* 任务目标:实现外部 Go/Python AI 控制器的接入。
|
||||
* 关键技术栈:Connect-RPC (@connectrpc/connect-web), Protobuf。
|
||||
* 核心模块:
|
||||
* 契约生成:使用 Protobuf 将 PluginInput 和 PluginOutput 标准化,生成两端的接口代码。
|
||||
* RPC 适配器:在前端 Rules Engine 中编写一个特殊的 Plugin Adapter,遇到远程规则时,通过 Connect 将数据发往 Go 后端服务,并等待返回 JSON Patch。
|
||||
* 动态 UI 渲染器:重构阅读页面,使其不再依赖硬编码字段,而是根据 Metadata 动态挂载组件(如检测到 ttsUrl 挂载播放器)。
|
||||
Milestone 4: 遥测与批量聚合处理 (Telemetry & Aggregation)
|
||||
* 任务目标:实现多状态 Filter 与用户阅读数据的反馈回路。
|
||||
* 关键技术栈:RxJS (bufferCount, bufferTime), Intersection Observer API。
|
||||
* 核心模块:
|
||||
* 批处理调度器:针对 Aggregation 阶段,使用 RxJS 的缓冲操作符,在达到数量或时间阈值时触发特定的 RPC 聚合调用。
|
||||
* 阅读统计探针:基于页面的滚动事件与 Intersection Observer,计算用户的实际停留时间与可见内容量,生成打分日志。
|
||||
* 反馈推流机制:周期性地将本地存储的遥测日志通过网络发送给后端模型训练/微调服务。
|
||||
@@ -0,0 +1,513 @@
|
||||
# RSSPipe: Product Requirements Document (PRD)
|
||||
|
||||
> **Document Version:** 1.0.0
|
||||
> **Status:** Approved / Ready for Implementation
|
||||
> **Target Audience:** Engineering, Architecture, Product
|
||||
> **Replaces:** Miniflux Deployment
|
||||
> **Deployment Target:** k3s / Kubernetes (Single Container Architecture with Separate or Embedded DB)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary & Product Vision
|
||||
|
||||
### 1.1 Overview
|
||||
|
||||
**RSSPipe** is a self-hosted, RSS-driven **Information Gateway** and intelligent reading platform built to replace traditional RSS aggregators (such as Miniflux).
|
||||
|
||||
Rather than serving purely as a passive feed bucket, RSSPipe combines:
|
||||
|
||||
1. **Google Reader & Gmail UX Philosophy**: Rapid, keyboard-first triaging (`j`/`k`, `o`, `v`, `s`, `e`), "Inbox Zero" workflow, dual view modes (expanded scrolling stream vs. compact list/split-pane), and Gmail-style search syntax.
|
||||
2. **Dual-Tier Feed Hooks Engine**:
|
||||
- **Single-Feed Enrichment**: Real-time readability full-text scraping, AI summarization/takeaways, relevance scoring, auto-tagging, TTS generation, and automatic public share-page publishing.
|
||||
- **Multi-Feed AI Digest Synthesis**: Scheduled or threshold-based multi-document synthesis that rolls up unread items into clean "Virtual Digest Feeds" (e.g., Daily Morning Briefing, Topic Radar) with synthesized audio podcasts, automatically archiving raw individual items.
|
||||
3. **Single-User Security with Public Sharing**: Single admin user model protecting the private gateway, with frictionless unauthenticated public sharing via tokenized URLs (`/share/<uuid>.html`).
|
||||
4. **Offline-Capable PWA & Native Ecosystem Support**: Installable Progressive Web App (PWA) with responsive mobile/desktop interfaces, alongside a Fever / Google Reader API compatibility layer for native mobile clients (e.g., NetNewsWire, Reeder).
|
||||
5. **Unified K3s-Ready Architecture**: High-performance Go backend with embedded React/Vite PWA frontend, packaged as a single lightweight Docker container targeting k3s/Kubernetes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architecture & System Tenets
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ RSSPipe Web App │
|
||||
│ (React + Vite PWA / Google Reader & Gmail UX) │
|
||||
└───────────┬────────────────────────────────┬───────────┘
|
||||
│ REST / SSE Events │ Fever / GReader API
|
||||
▼ ▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ Go Unified Server │
|
||||
│ │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Auth & Session │ │ Ingestion Pool │ │ Public Share │ │
|
||||
│ │ (Single Admin) │ │ (Cron & Web Crawl) │ │ (/share/<uuid>) │ │
|
||||
│ └────────────────────┘ └─────────┬──────────┘ └─────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Rules & Hook Engine (DAG) │ │
|
||||
│ │ ┌───────────────────────────────┐ ┌──────────────────────────┐ │ │
|
||||
│ │ │ Single-Feed Enrichment │ │ Multi-Feed Aggregation │ │ │
|
||||
│ │ │ (Readability/LLM/TTS/Webhook) │ │ (Digest Synthesis/Audio) │ │ │
|
||||
│ │ └───────────────────────────────┘ └──────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────┬───────────────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Data Layer (PostgreSQL / SQLite WAL + FTS5 Full-Text) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 Design Tenets
|
||||
|
||||
1. **Speed over Friction**: Sub-100ms UI responses, instant keyboard navigation, and optimistic UI updates.
|
||||
2. **Information High Signal-to-Noise**: Raw RSS feeds are enriched or distilled into digestible summaries and podcasts so the user stays informed without drowning in unread counts.
|
||||
3. **Zero Maintenance & Single Container Simplicity**: Single binary/image containing backend server, worker pools, and embedded web assets, deployable via simple Helm chart / Kubernetes manifests on k3s.
|
||||
4. **Cycle-Safe Rule Processing**: All feed transformation steps follow a strict Directed Acyclic Graph (DAG) with topological validation to prevent infinite execution loops.
|
||||
|
||||
---
|
||||
|
||||
## 3. End-to-End User Journeys
|
||||
|
||||
```mermaid
|
||||
journey
|
||||
title Core User Journeys in RSSPipe
|
||||
section 1. Onboarding & Migration
|
||||
Import OPML from Miniflux: 5: Admin
|
||||
Auto-categorize and test feeds: 4: Admin
|
||||
section 2. Daily Triaging (Google Reader / Gmail UX)
|
||||
Open PWA (Desktop/Mobile): 5: Admin
|
||||
Navigate feeds with 'j'/'k' and expand with 'o': 5: Admin
|
||||
Quick triage with 'e' (archive/read) and 's' (star): 5: Admin
|
||||
Search with operators 'is:unread tag:ai': 5: Admin
|
||||
section 3. Feed Hooks & Enrichment
|
||||
Ingest feed & scrape full-text via Readability: 5: System
|
||||
LLM summarizes & scores relevance: 5: System
|
||||
Generate TTS audio preview: 4: System
|
||||
section 4. Multi-Feed Digest Generation
|
||||
Accumulate > 15 topic feeds or reach 8:00 AM: 5: System
|
||||
Synthesize AI Daily Digest & Audio Podcast: 5: System
|
||||
Publish to Virtual Digest Feed & mark raw items read: 5: System
|
||||
section 5. Knowledge Sharing
|
||||
Click 'Share to Public' on article/digest: 5: Admin
|
||||
External visitor views /share/<uuid>.html: 5: Public Reader
|
||||
```
|
||||
|
||||
### Journey 1: One-Click Migration from Miniflux (Onboarding)
|
||||
|
||||
- **Goal**: Seamlessly transition existing feeds, categories, and folder hierarchies from Miniflux to RSSPipe in seconds.
|
||||
- **Flow**:
|
||||
1. User navigates to `Settings -> Migration & OPML`.
|
||||
2. User uploads Miniflux `subscriptions.opml`.
|
||||
3. System parses categories and feed URLs, tests connection concurrency in the background, and preserves category folder tree.
|
||||
4. System prompts option to backfill recent posts (last N days) or start fresh from latest entries.
|
||||
5. Dashboard immediately renders feeds categorized and organized ready for consumption.
|
||||
|
||||
### Journey 2: Morning Triage (Google Reader & Gmail Workflow)
|
||||
|
||||
- **Goal**: Rapidly process 200+ unread items in under 5 minutes using keyboard shortcuts and split views.
|
||||
- **Flow**:
|
||||
1. User opens RSSPipe PWA. The "Inbox Zero" view displays all unread entries grouped by priority or chronological stream.
|
||||
2. User toggles view:
|
||||
- **Expanded Stream (Google Reader)**: Full article body inline, scrolling continuously with `Space` or `j`/`k`.
|
||||
- **Compact List (Gmail)**: Single-line item rows displaying Source, Star, Title, AI Summary badge, and Time.
|
||||
3. User presses `j` to focus the next item, `o` or `Enter` to expand/collapse.
|
||||
4. User presses `s` to star for deep reading later, `v` to open original link in a new tab, `m` to toggle read/unread, or `e` to archive/mark read.
|
||||
5. User presses `/` to activate search and types `is:unread tag:ai score:>80` to filter high-impact items instantly.
|
||||
|
||||
### Journey 3: Single-Feed Automated Enrichment Hook
|
||||
|
||||
- **Goal**: Automatically convert truncated RSS feeds into readable, summarized, and categorized entries with voice synthesis.
|
||||
- **Flow**:
|
||||
1. Ingestion engine fetches a newly published feed item.
|
||||
2. **Content Extraction**: System detects truncated content and runs the backend Readability scraper to fetch the full clean article markdown.
|
||||
3. **AI Enrichment**: Configured rule triggers LLM prompt:
|
||||
- Generates a 3-bullet executive summary.
|
||||
- Assigns relevant topic tags (`#llm`, `#k8s`).
|
||||
- Scores article relevance from 1 to 100 based on user interest profile.
|
||||
4. **TTS Audio Hook**: High-scoring items automatically trigger Edge-TTS / OpenAI TTS generation, attaching an audio player to the feed header.
|
||||
5. **Notification Hook**: If relevance `score >= 90`, sends a webhook alert to user's Telegram/Discord channel with the summary and direct link.
|
||||
|
||||
### Journey 4: Multi-Feed AI Digest Synthesis & "Inbox Zero" Auto-Rollup
|
||||
|
||||
- **Goal**: Eliminate RSS fatigue by condensing tens of high-volume feeds into a single curated morning/evening brief.
|
||||
- **Flow**:
|
||||
1. User sets a Multi-Feed Aggregation Rule: *"Every day at 7:30 AM (or whenever unread items in #tech exceed 20), synthesize a 5-minute Executive Digest and mark source items as read."*
|
||||
2. At the scheduled time / threshold, the Multi-Feed Engine queries all eligible unread items.
|
||||
3. The Engine passes all article snippets/full-text into the LLM with a structured synthesis prompt:
|
||||
- Grouping into major themes / top headlines.
|
||||
- Highlighting key takeaways, contradictions, or trends across sources.
|
||||
- Linking back to source items.
|
||||
4. An AI TTS audio briefing is synthesized and attached.
|
||||
5. The resulting synthesis is injected as a new entry into a **Virtual Digest Feed** (e.g., `📰 Daily Tech Executive Briefing`).
|
||||
6. The 20+ raw underlying source feeds are automatically marked as read / archived.
|
||||
7. When user opens the PWA, they see 1 high-quality digest episode instead of 20 fragmented articles.
|
||||
|
||||
### Journey 5: Knowledge Sharing to External Visitors
|
||||
|
||||
- **Goal**: Share an insightful article or AI digest with friends, colleagues, or social followers without granting access to the private RSS reader.
|
||||
- **Flow**:
|
||||
1. In the PWA reader view, user clicks the **"Share to Public"** icon (or an automated rule publishes high-scoring digests automatically).
|
||||
2. Backend generates an immutable, non-guessable UUID share route: `https://rss.domain.com/share/550e8400-e29b-41d4-a716-446655440000.html`.
|
||||
3. The generated page is a standalone, beautifully styled, mobile-friendly HTML view containing:
|
||||
- Article/Digest Title, Source attribution, and publication date.
|
||||
- Audio player (if TTS generated).
|
||||
- AI Summary & Full Markdown Body.
|
||||
4. External readers can access the link without any login or authentication prompts.
|
||||
|
||||
### Journey 6: Multi-Device Sync & Third-Party Native App Support
|
||||
|
||||
- **Goal**: Read on mobile (e.g., NetNewsWire / Reeder on iOS/macOS) while traveling, and continue on desktop PWA at work.
|
||||
- **Flow**:
|
||||
1. User configures NetNewsWire using the built-in **Fever API** or **Google Reader API** endpoint credentials (`https://rss.domain.com/api/greader`).
|
||||
2. Mobile app synchronizes unread states, starred articles, and folders in real time.
|
||||
3. Read state changes on mobile reflect instantly on the web PWA via Go backend sync.
|
||||
|
||||
---
|
||||
|
||||
## 4. Functional Requirements & Feature Specifications
|
||||
|
||||
### 4.1 Single-User Authentication & Security
|
||||
|
||||
| Requirement ID | Feature | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **AUTH-01** | Single Admin Entry | Database stores single admin user account with hashed password (Argon2id / bcrypt). |
|
||||
| **AUTH-02** | Session & JWT Auth | Secure, HTTP-only Cookie-based session for Web PWA, plus persistent API Tokens for third-party apps and CLI access. |
|
||||
| **AUTH-03** | Public Share Isolation | Endpoints under `/share/*` bypass authentication entirely; all administrative, ingestion, and reader endpoints require valid admin credentials. |
|
||||
| **AUTH-04** | Security Headers & Rate Limiting | Built-in rate limiting on login/API endpoints, CSRF protection, and customizable CORS headers. |
|
||||
|
||||
### 4.2 Ingestion & Feed Management
|
||||
|
||||
| Requirement ID | Feature | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **FEED-01** | Multi-Format Parsing | Supports RSS 0.9x/1.0/2.0, Atom 1.0, and JSON Feed 1.0/1.1 formats. |
|
||||
| **FEED-02** | Concurrency Worker Pool | Background worker pool in Go with configurable concurrency, rate limits per domain, and exponential backoff retry. |
|
||||
| **FEED-03** | Conditional GET / ETag | Utilizes `If-Modified-Since` and `ETag` headers to minimize network traffic and server load. |
|
||||
| **FEED-04** | OPML Import / Export | Full OPML 2.0 import and export support, maintaining folder hierarchy and feed settings. |
|
||||
| **FEED-05** | Category & Tag Hierarchy | Supports nesting feeds under Categories/Folders as well as applying multi-dimensional Tags. |
|
||||
|
||||
### 4.3 Content Processing & Scraper Engine
|
||||
|
||||
| Requirement ID | Feature | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **PROC-01** | Readability Full-Text Scraper | Built-in readability engine (extracts clean article HTML/Markdown from original URL for truncated feeds). |
|
||||
| **PROC-02** | HTML Sanitization & Media Proxy | Strict XSS sanitization (DOMPurify / Bluemonday) and optional image caching/proxying to avoid mixed-content and tracking pixels. |
|
||||
| **PROC-03** | Content Deduplication | SHA-256 content hashing and URL canonicalization to detect duplicate articles across feeds. |
|
||||
|
||||
### 4.4 Dual-Tier Rules Engine & Feed Hooks (DAG Architecture)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Ingestion Phase
|
||||
A[Fetch RSS / Atom] --> B[Parse XML & Normalize]
|
||||
B --> C[Readability Full-Text Extraction]
|
||||
end
|
||||
|
||||
subgraph Enrichment Phase (Single-Feed Hooks)
|
||||
C --> D{Single-Feed Rules Match?}
|
||||
D -->|Match| E1[LLM Summarization & Scoring]
|
||||
D -->|Match| E2[Edge-TTS / Audio Generation]
|
||||
D -->|Match| E3[Auto-Tag / Star / Drop]
|
||||
D -->|Match| E4[Outbound Webhook Telegram/Discord]
|
||||
D -->|Match| E5[Publish to /share/uuid.html]
|
||||
end
|
||||
|
||||
subgraph Storage & Indexing
|
||||
E1 & E2 & E3 & E4 & E5 & D -->|Persist| F[(Database & FTS Index)]
|
||||
end
|
||||
|
||||
subgraph Aggregation Phase (Multi-Feed Hooks)
|
||||
F --> G{Aggregation Trigger Fired?}
|
||||
G -->|Threshold > N items OR Schedule Cron| H[Batch Fetch Unread Target Feeds]
|
||||
H --> I[LLM Multi-Document Synthesis]
|
||||
I --> J[Generate Audio Podcast]
|
||||
J --> K[Inject to Virtual Digest Feed]
|
||||
K --> L[Batch Mark Source Items as Read]
|
||||
end
|
||||
```
|
||||
|
||||
#### A. Single-Feed Hooks (Per-Item Pipeline)
|
||||
|
||||
- **Triggers**: On Ingestion (`on_feed_ingest`), On User Star (`on_item_star`), On Tag Added (`on_item_tag`).
|
||||
- **Conditions**: Source Feed / Category, Title/Content Regex, Author, Word Count, Language, Metadata matching.
|
||||
- **Actions**:
|
||||
1. `ai_summarize`: Send article content to configured OpenAI-compatible endpoint with custom system prompt; save summary to metadata.
|
||||
2. `ai_score`: Compute relevance score (0–100) and rationale.
|
||||
3. `generate_tts`: Synthesize audio via Edge-TTS / OpenAI TTS / ElevenLabs and store media link.
|
||||
4. `auto_tag` / `auto_star` / `mark_read` / `drop_item`: Modify feed state automatically.
|
||||
5. `create_public_share`: Automatically generate a public share link `/share/<uuid>.html`.
|
||||
6. `send_webhook`: Dispatch JSON payload or message formatting to Telegram, Discord, Slack, or generic HTTP webhook (n8n/Zapier).
|
||||
|
||||
#### B. Multi-Feed Hooks (Batch & Temporal Aggregation)
|
||||
|
||||
- **Triggers**:
|
||||
- `schedule`: Cron expression (e.g. `30 7 * * *` for 7:30 AM daily).
|
||||
- `threshold`: When unread items matching criteria $\ge N$ (e.g. $> 15$ unread articles in Category "AI & ML").
|
||||
- **Conditions**: Category, Tag filter, Keyword filter, Unread status.
|
||||
- **Actions**:
|
||||
1. `synthesize_digest`: Execute multi-document LLM prompt to generate an editorial digest with categorized trends and citations.
|
||||
2. `create_virtual_digest_feed`: Inject the synthesized article into an internal "Virtual Digest Feed" (e.g., "Daily AI Brief").
|
||||
3. `generate_digest_podcast`: Synthesize speech for the full digest episode and attach audio file.
|
||||
4. `batch_archive_sources`: Automatically mark the individual source articles as read/archived, maintaining Inbox Zero.
|
||||
|
||||
#### C. DAG Safety & Anti-Loop Validation
|
||||
|
||||
- All rule definitions are validated upon creation/update via Topological Sort.
|
||||
- Cyclic dependencies (e.g., Rule A creates Feed Item that triggers Rule B, which triggers Rule A) are rejected at configuration time.
|
||||
- Hard recursion depth cap (depth = 3) on cascading triggers.
|
||||
|
||||
### 4.5 Reading Experience & UI / UX Specifications
|
||||
|
||||
```
|
||||
+-----------------------------------------------------------------------------------------------+
|
||||
| RSSPipe [Logo] [ Search: is:unread tag:tech / ] [Theme] [Sync] [Admin Profile]|
|
||||
+-------------------+---------------------------------------------------------------------------+
|
||||
| + Add Subscription| (Inbox: 12 unread) [ Mark All Read ] [ View: Stream | Compact ] [Sort] |
|
||||
| ----------------- +---------------------------------------------------------------------------+
|
||||
| * Inbox (12) | [ ] * [Hacker News] Building Local-First PWA with SQLite 10m ago |
|
||||
| * Starred (4) | AI Summary: Explains sync protocols and CRDTs... (Score: 92) |
|
||||
| * Digests (2) | ------------------------------------------------------------------------- |
|
||||
| - Morning Brief | [ ] [Ars Technica] Quantum Computing Milestone Announced 45m ago |
|
||||
| - Weekly Radar | ------------------------------------------------------------------------- |
|
||||
| v CATEGORIES | [ ] * [Virtual Digest] Morning AI Briefing (5 mins audio) 7:30 AM |
|
||||
| > Tech (8) | [ ▶ Play Audio 05:21 ] Full synthesized summary of 18 posts... |
|
||||
| > Science (4) | ------------------------------------------------------------------------- |
|
||||
+-------------------+---------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
#### Dual View Modes
|
||||
|
||||
1. **Google Reader Expanded Stream View**:
|
||||
- Continuous scroll through full article cards.
|
||||
- Header with Feed Title, Favicon, Author, Publish Date, AI Summary pill, Audio Player bar (if present), and Actions (Star, Share, Original URL).
|
||||
- Clean typographical hierarchy with readable line lengths (65–80 characters max for text body), syntax-highlighted code blocks, and responsive image scaling.
|
||||
2. **Gmail Compact List / Split-Pane View**:
|
||||
- High-density list: Checkbox, Star icon, Source Feed badge, Article Title, 1-line snippet/AI Summary preview, Timestamp.
|
||||
- Split-pane layout: Clicking an item opens the reading pane on the right or below, retaining the list for fast scanning.
|
||||
|
||||
#### Keyboard Shortcut Matrix (Google Reader & Gmail Paradigm)
|
||||
|
||||
| Key Binding | Action |
|
||||
| :--- | :--- |
|
||||
| `j` / `k` | Navigate to Next / Previous article |
|
||||
| `Space` / `Shift + Space` | Page Down / Page Up through stream |
|
||||
| `o` or `Enter` | Expand / Collapse current item in compact view |
|
||||
| `v` | Open original article URL in a new browser tab |
|
||||
| `s` | Star / Unstar current item |
|
||||
| `m` | Toggle Read / Unread status |
|
||||
| `e` or `Shift + I` | Archive / Mark as Read and advance |
|
||||
| `Shift + A` | Mark all items in current view as read (with confirmation modal) |
|
||||
| `/` | Focus search bar |
|
||||
| `g` then `i` | Go to Inbox |
|
||||
| `g` then `s` | Go to Starred |
|
||||
| `g` then `d` | Go to Virtual Digests |
|
||||
| `?` | Open keyboard shortcuts help modal |
|
||||
|
||||
#### PWA & Offline Support
|
||||
|
||||
- Fully installable PWA with Web App Manifest and Service Worker caching.
|
||||
- IndexedDB client caching (Dexie.js) allowing offline reading of already synced unread/starred articles and summaries.
|
||||
- Offline action queuing (marking read, starring while offline syncs automatically when connection is restored).
|
||||
|
||||
### 4.6 Search & Query Syntax (Gmail-Style Engine)
|
||||
|
||||
The search bar supports advanced tokenized queries backed by database Full-Text Search:
|
||||
|
||||
- `is:unread` / `is:read` / `is:starred`
|
||||
- `tag:tech` / `category:engineering`
|
||||
- `feed:"Hacker News"` / `from:author_name`
|
||||
- `has:summary` / `has:audio` / `has:share`
|
||||
- `score:>80` / `score:<50`
|
||||
- `after:2026-08-01` / `before:2026-08-20`
|
||||
- Raw keyword and phrase matching: `"generative ai" kubernetes`
|
||||
|
||||
### 4.7 API & Ecosystem Integrations
|
||||
|
||||
1. **Fever API & Google Reader API**:
|
||||
- `/api/fever`: Implements standard Fever protocol endpoints (`?api`, `?items`, `?feeds`, `?groups`, `?unread_item_ids`, `?saved_item_ids`).
|
||||
- `/api/greader`: Implements Google Reader API endpoints (`/reader/api/0/...`) for NetNewsWire, Reeder, and FeedMe.
|
||||
2. **AI / LLM Integration**:
|
||||
- Generic OpenAI-compatible client (configurable `base_url`, `api_key`, `model_name`, `temperature`, `max_tokens`).
|
||||
- Supports OpenAI (GPT-4o/Mini), Anthropic (via OpenAI proxy/native), Google Gemini, DeepSeek, Ollama, and LocalAI.
|
||||
3. **TTS Integration**:
|
||||
- Free Microsoft Edge-TTS integration (high-quality neural voices with zero API key requirement).
|
||||
- Optional OpenAI TTS (`tts-1`, `tts-1-hd`) and ElevenLabs API support.
|
||||
4. **Outbound Notification Webhooks**:
|
||||
- Native Telegram Bot API formatting.
|
||||
- Discord / Slack incoming webhook formatting.
|
||||
- Generic HTTP POST with configurable headers and JSON template.
|
||||
|
||||
---
|
||||
|
||||
## 5. Technical Architecture & Data Schema
|
||||
|
||||
### 5.1 Data Models (Relational & Flexible Metadata)
|
||||
|
||||
```sql
|
||||
-- 1. Users Table (Single Admin Entry)
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. Categories Table
|
||||
CREATE TABLE categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(128) NOT NULL,
|
||||
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 3. Feeds Table
|
||||
CREATE TABLE feeds (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
feed_url TEXT UNIQUE NOT NULL,
|
||||
site_url TEXT,
|
||||
category_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||
is_virtual_digest BOOLEAN DEFAULT FALSE,
|
||||
refresh_interval_minutes INT DEFAULT 30,
|
||||
etag VARCHAR(255),
|
||||
last_modified VARCHAR(255),
|
||||
last_fetched_at TIMESTAMPTZ,
|
||||
fetch_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 4. Feed Entries Table
|
||||
CREATE TABLE feed_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
feed_id UUID NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
||||
guid TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
author VARCHAR(255),
|
||||
content TEXT NOT NULL, -- Cleaned full-text / readability content
|
||||
raw_content TEXT, -- Original feed snippet
|
||||
published_at TIMESTAMPTZ NOT NULL,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
is_starred BOOLEAN DEFAULT FALSE,
|
||||
share_uuid UUID UNIQUE, -- Non-null if published to /share/<uuid>.html
|
||||
|
||||
-- Dynamic Metadata & Extensibility (JSONB)
|
||||
-- Contains: { "aiSummary": "...", "score": 85, "tags": ["ai", "go"], "ttsUrl": "..." }
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT unique_feed_guid UNIQUE(feed_id, guid)
|
||||
);
|
||||
|
||||
-- Indexes for Fast Triage and FTS
|
||||
CREATE INDEX idx_entries_feed_read ON feed_entries(feed_id, is_read);
|
||||
CREATE INDEX idx_entries_published ON feed_entries(published_at DESC);
|
||||
CREATE INDEX idx_entries_share_uuid ON feed_entries(share_uuid) WHERE share_uuid IS NOT NULL;
|
||||
CREATE INDEX idx_entries_metadata_gin ON feed_entries USING GIN (metadata);
|
||||
|
||||
-- 5. Rules & Hooks Table
|
||||
CREATE TABLE rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(128) NOT NULL,
|
||||
rule_type VARCHAR(32) NOT NULL, -- 'single_feed' | 'multi_feed_digest'
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
trigger_type VARCHAR(32) NOT NULL, -- 'on_ingest' | 'schedule' | 'threshold'
|
||||
trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb, -- { "cron": "0 8 * * *", "threshold": 15 }
|
||||
conditions JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{ "field": "category", "op": "eq", "value": "tech" }]
|
||||
actions JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{ "action": "ai_summarize" }, { "action": "generate_tts" }]
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 Deployment & Packaging Specification
|
||||
|
||||
- **Single Docker Container**:
|
||||
- Base Image: `golang:1.23-alpine` (builder) + `alpine:latest` (runtime).
|
||||
- Web Assets: Built using `vite build` and embedded into the Go binary using `//go:embed dist/*`.
|
||||
- Database Support: Built-in driver for PostgreSQL (via `pgx`) and SQLite3 with WAL mode enabled.
|
||||
- **K3s / Kubernetes Manifests**:
|
||||
- `Deployment`: 1 replica, resource limits ~64MB RAM / 0.1 CPU idle.
|
||||
- `Service` & `Ingress`: Traefik ingress exposing web routes and public `/share/*` routes.
|
||||
- `PersistentVolumeClaim` (optional for SQLite storage or local audio cache).
|
||||
|
||||
---
|
||||
|
||||
## 6. Milestone Roadmap & Implementation Breakdown
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title RSSPipe Implementation Roadmap
|
||||
dateFormat YYYY-MM-DD
|
||||
section M1: Core Foundation
|
||||
Go Server, DB & Single-User Auth :m1_1, 2026-09-01, 7d
|
||||
Ingestion Engine & OPML Migration :m1_2, after m1_1, 7d
|
||||
section M2: PWA & Reading UX
|
||||
React Vite PWA Setup & Theming :m2_1, after m1_2, 5d
|
||||
Google Reader & Gmail Keyboards / UI :m2_2, after m2_1, 7d
|
||||
Gmail Search Engine & Offline Cache :m2_3, after m2_2, 5d
|
||||
section M3: Single-Feed Hooks
|
||||
Readability Full-Text Scraper :m3_1, after m2_3, 4d
|
||||
LLM Summarization & Scoring Hooks :m3_2, after m3_1, 5d
|
||||
Public Share /share/<uuid>.html :m3_3, after m3_2, 4d
|
||||
Outbound Webhooks (Telegram/Discord) :m3_4, after m3_3, 3d
|
||||
section M4: Multi-Feed Digest & Audio
|
||||
Multi-Feed Batch Aggregator :m4_1, after m3_4, 6d
|
||||
Virtual Digest Feeds & Auto-Archive :m4_2, after m4_1, 5d
|
||||
Edge-TTS & Audio Player Integration :m4_3, after m4_2, 5d
|
||||
section M5: Ecosystem & Polishing
|
||||
Fever / Google Reader API Layer :m5_1, after m4_3, 6d
|
||||
Rules UI Builder & DAG Validation :m5_2, after m5_1, 5d
|
||||
K3s Deployment & E2E Validation :m5_3, after m5_2, 4d
|
||||
```
|
||||
|
||||
### Milestone 1: Core Foundation & Ingestion Engine
|
||||
|
||||
- [ ] Go backend server skeleton with Chi/Echo router and embedded static server.
|
||||
- [ ] Database layer supporting PostgreSQL and SQLite (WAL mode).
|
||||
- [ ] Single admin user authentication (Session Cookies, JWT, bcrypt/Argon2id).
|
||||
- [ ] Multi-format RSS/Atom/JSON parser with concurrent fetch worker pool.
|
||||
- [ ] OPML 2.0 import/export engine for seamless Miniflux migration.
|
||||
- [ ] Single Dockerfile packaging backend + frontend ready for k3s deployment.
|
||||
|
||||
### Milestone 2: PWA Frontend & Google Reader / Gmail UX
|
||||
|
||||
- [ ] React + Vite PWA boilerplate with Tailwind/Vanilla CSS design system, dark/light modes, and responsive layout.
|
||||
- [ ] Dual view modes: Google Reader continuous expanded stream + Gmail compact list/split-pane.
|
||||
- [ ] Full keyboard navigation workflow (`j`, `k`, `o`, `v`, `s`, `m`, `e`, `Shift+A`, `/`).
|
||||
- [ ] Service Worker and IndexedDB (Dexie.js) for local caching and offline reading.
|
||||
- [ ] Gmail-style tokenized search bar (`is:unread`, `tag:xxx`, `score:>80`, etc.).
|
||||
|
||||
### Milestone 3: Single-Feed Hooks & Enrichment
|
||||
|
||||
- [ ] Backend readability scraper to extract clean full-text markdown for truncated feeds.
|
||||
- [ ] Single-feed rules engine pipeline with trigger-condition-action execution.
|
||||
- [ ] OpenAI-compatible LLM connector for automated 3-bullet summary, key takeaways, and relevance scoring (0–100).
|
||||
- [ ] Public share generator creating standalone `/share/<uuid>.html` accessible without auth.
|
||||
- [ ] Outbound webhook dispatcher (Telegram, Discord, Slack, generic HTTP POST).
|
||||
|
||||
### Milestone 4: Multi-Feed Digest Synthesis & Audio (TTS)
|
||||
|
||||
- [ ] Multi-feed batch scheduler with cron and count-threshold triggers.
|
||||
- [ ] Multi-document LLM synthesis engine for creating structured daily/topic briefs.
|
||||
- [ ] Virtual Digest Feeds architecture (internal native feeds receiving synthesized articles).
|
||||
- [ ] Batch auto-archive / auto-mark read mechanism for processed source articles.
|
||||
- [ ] Audio generation engine (Edge-TTS and OpenAI TTS) with embedded audio player in PWA and public share pages.
|
||||
|
||||
### Milestone 5: Ecosystem Compatibility & Final Polish
|
||||
|
||||
- [ ] Fever API (`/api/fever`) and Google Reader API (`/api/greader`) implementation for native mobile app sync (NetNewsWire, Reeder).
|
||||
- [ ] Visual Rule Builder in Settings with DAG cycle validator (Topological Sort).
|
||||
- [ ] Telemetry & Reading Analytics (dwell time, reading progress).
|
||||
- [ ] Complete k3s deployment manifests (Deployment, Service, Ingress, PVC) and comprehensive documentation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Success Metrics & Verification Criteria
|
||||
|
||||
1. **Migration Fidelity**: 100% of feeds and categories from Miniflux OPML imported cleanly without data loss.
|
||||
2. **Triaging Speed**: Zero-lag keyboard navigation (`j`/`k`) with UI frame times strictly $< 16\text{ms}$ (60 FPS).
|
||||
3. **Resource Efficiency**: Single container idling at $< 64\text{MB}$ RAM and $< 1\%$ CPU on k3s cluster.
|
||||
4. **Hook Reliability**: 100% of ingestion hooks executed asynchronously without blocking feed fetch workers; zero DAG execution cycles.
|
||||
5. **Ecosystem Compatibility**: NetNewsWire / Reeder connect and synchronize unread/starred states without errors.
|
||||
@@ -0,0 +1,24 @@
|
||||
module github.com/haopengzhan/rsspipe
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-chi/chi/v5 v5.3.2 // indirect
|
||||
github.com/go-chi/cors v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mmcdole/gofeed v1.4.2 // indirect
|
||||
github.com/mmcdole/goxpp/v2 v2.0.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.57.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mmcdole/gofeed v1.4.2 h1:XFFOtsNNZg+zudjtMXb8BI0J/YdnSIGfjEpPgPnZib0=
|
||||
github.com/mmcdole/gofeed v1.4.2/go.mod h1:X5x1PyeibJi152VEya0AsV+PW4daYmCD4LJaJbeFkcs=
|
||||
github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ=
|
||||
github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
@@ -0,0 +1,530 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/haopengzhan/rsspipe/internal/auth"
|
||||
"github.com/haopengzhan/rsspipe/internal/crawler"
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
"github.com/haopengzhan/rsspipe/internal/opml"
|
||||
"github.com/haopengzhan/rsspipe/internal/parser"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *db.DB
|
||||
auth *auth.AuthService
|
||||
crawler *crawler.Crawler
|
||||
parser *parser.Parser
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler) *Handler {
|
||||
return &Handler{
|
||||
db: database,
|
||||
auth: authService,
|
||||
crawler: cr,
|
||||
parser: parser.NewParser(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, status int, msg string) {
|
||||
h.writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// Auth Handlers
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.auth.Authenticate(req.Username, req.Password)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusUnauthorized, "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "rsspipe_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
user, _ := h.db.GetUserByUsername(req.Username)
|
||||
h.writeJSON(w, http.StatusOK, models.LoginResponse{
|
||||
Token: token,
|
||||
User: *user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "rsspipe_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
})
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||
if !ok {
|
||||
h.writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
user, err := h.db.GetUserByUsername(claims.Username)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// Stats Handler
|
||||
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.db.GetStats()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// Category Handlers
|
||||
func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := h.db.ListCategories()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if cats == nil {
|
||||
cats = []models.Category{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, cats)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Title) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "valid category title is required")
|
||||
return
|
||||
}
|
||||
|
||||
cat, err := h.db.CreateCategory(strings.TrimSpace(body.Title), body.ParentID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusCreated, cat)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.db.DeleteCategory(id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "category deleted"})
|
||||
}
|
||||
|
||||
// Feed Handlers
|
||||
func (h *Handler) ListFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.db.ListFeeds()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if feeds == nil {
|
||||
feeds = []models.Feed{}
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, feeds)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreateFeedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.FeedURL) == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "valid feed_url is required")
|
||||
return
|
||||
}
|
||||
|
||||
req.FeedURL = strings.TrimSpace(req.FeedURL)
|
||||
|
||||
// Check if already subscribed
|
||||
if existing, _ := h.db.GetFeedByURL(req.FeedURL); existing != nil {
|
||||
h.writeError(w, http.StatusConflict, "feed is already subscribed")
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch to test and discover title/site URL
|
||||
res, err := h.parser.FetchFeed(r.Context(), req.FeedURL, "", "")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, fmt.Sprintf("failed to fetch feed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
title := res.Title
|
||||
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
|
||||
title = strings.TrimSpace(*req.Title)
|
||||
}
|
||||
if title == "" {
|
||||
title = req.FeedURL
|
||||
}
|
||||
|
||||
feed, err := h.db.CreateFeed(title, req.FeedURL, res.SiteURL, req.CategoryID)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Insert parsed entries
|
||||
if len(res.Entries) > 0 {
|
||||
_, _ = h.db.UpsertFeedEntries(feed.ID, res.Entries)
|
||||
_ = h.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "")
|
||||
}
|
||||
|
||||
// Retrieve updated feed with counts
|
||||
fullFeed, err := h.db.GetFeed(feed.ID)
|
||||
if err != nil {
|
||||
h.writeJSON(w, http.StatusCreated, feed)
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusCreated, fullFeed)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req models.UpdateFeedRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateFeed(id, req); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fullFeed, err := h.db.GetFeed(id)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, fullFeed)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFeed(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.db.DeleteFeed(id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "feed deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshFeed(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.crawler.RefreshSingle(r.Context(), id); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
f, _ := h.db.GetFeed(id)
|
||||
h.writeJSON(w, http.StatusOK, f)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshAllFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
go h.crawler.RefreshAll(context.Background())
|
||||
h.writeJSON(w, http.StatusAccepted, map[string]string{"message": "refreshing all feeds in background"})
|
||||
}
|
||||
|
||||
// Entry Handlers
|
||||
func (h *Handler) ListEntries(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
offset, _ := strconv.Atoi(q.Get("offset"))
|
||||
|
||||
filter := db.EntryFilter{
|
||||
View: q.Get("view"),
|
||||
FeedID: q.Get("feed_id"),
|
||||
CategoryID: q.Get("category_id"),
|
||||
Search: q.Get("search"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
entries, total, err := h.db.ListEntries(filter)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []models.FeedEntry{}
|
||||
}
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"entries": entries,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateEntryRead(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body models.UpdateEntryReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateEntryRead(id, body.IsRead); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_read": body.IsRead})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateEntryStar(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var body models.UpdateEntryStarRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateEntryStarred(id, body.IsStarred); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{"id": id, "is_starred": body.IsStarred})
|
||||
}
|
||||
|
||||
func (h *Handler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
var body models.MarkAllReadRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
if err := h.db.MarkAllRead(body.FeedID, body.CategoryID); err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "marked all as read"})
|
||||
}
|
||||
|
||||
// OPML Handlers
|
||||
func (h *Handler) ImportOPML(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil { // 10MB max
|
||||
h.writeError(w, http.StatusBadRequest, "failed to parse multipart form")
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "file field is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "failed to read file")
|
||||
return
|
||||
}
|
||||
|
||||
importedFeeds, err := opml.ParseOPML(data)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid OPML XML: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
categoryCache := make(map[string]string) // title -> id
|
||||
cats, _ := h.db.ListCategories()
|
||||
for _, c := range cats {
|
||||
categoryCache[c.Title] = c.ID
|
||||
}
|
||||
|
||||
createdCount := 0
|
||||
for _, item := range importedFeeds {
|
||||
if item.FeedURL == "" {
|
||||
continue
|
||||
}
|
||||
if existing, _ := h.db.GetFeedByURL(item.FeedURL); existing != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var catID *string
|
||||
if item.CategoryTitle != "" {
|
||||
if cid, ok := categoryCache[item.CategoryTitle]; ok {
|
||||
catID = &cid
|
||||
} else {
|
||||
newCat, err := h.db.CreateCategory(item.CategoryTitle, nil)
|
||||
if err == nil {
|
||||
categoryCache[item.CategoryTitle] = newCat.ID
|
||||
catID = &newCat.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
title := item.Title
|
||||
if title == "" {
|
||||
title = item.FeedURL
|
||||
}
|
||||
|
||||
_, err := h.db.CreateFeed(title, item.FeedURL, item.SiteURL, catID)
|
||||
if err == nil {
|
||||
createdCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger background crawler for newly imported feeds
|
||||
go h.crawler.RefreshAll(context.Background())
|
||||
|
||||
h.writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"imported_count": createdCount,
|
||||
"total_parsed": len(importedFeeds),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ExportOPML(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := h.db.ListFeeds()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
cats, err := h.db.ListCategories()
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
xmlData, err := opml.GenerateOPML(feeds, cats)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="rsspipe-subscriptions.opml"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(xmlData)
|
||||
}
|
||||
|
||||
// Public Share handler (/share/<uuid>.html)
|
||||
func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
|
||||
uuidStr := chi.URLParam(r, "uuid")
|
||||
uuidStr = strings.TrimSuffix(uuidStr, ".html")
|
||||
|
||||
var e models.FeedEntry
|
||||
var fTitle, fSiteURL, author, rawContent, metaStr, shareUUID sql.NullString
|
||||
query := `
|
||||
SELECT
|
||||
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
|
||||
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
|
||||
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
|
||||
FROM feed_entries e
|
||||
JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE e.share_uuid = ? OR e.id = ?
|
||||
`
|
||||
err := h.db.QueryRow(query, uuidStr, uuidStr).Scan(
|
||||
&e.ID, &e.FeedID, &fTitle, &fSiteURL,
|
||||
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
|
||||
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Shared article not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if fTitle.Valid {
|
||||
e.FeedTitle = fTitle.String
|
||||
}
|
||||
if fSiteURL.Valid {
|
||||
e.FeedSiteURL = fSiteURL.String
|
||||
}
|
||||
if author.Valid {
|
||||
e.Author = author.String
|
||||
}
|
||||
|
||||
// Render standalone styled HTML
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s - RSSPipe</title>
|
||||
<style>
|
||||
:root { --bg: #0f172a; --card: #1e293b; --text: #f8fafc; --muted: #94a3b8; --accent: #38bdf8; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #f8fafc; --card: #ffffff; --text: #0f172a; --muted: #64748b; --accent: #0284c7; }
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; margin: 0; padding: 2rem 1rem; display: flex; justify-content: center; }
|
||||
.container { max-width: 760px; width: 100%%; background: var(--card); border-radius: 12px; padding: 2.5rem; box-shadow: 0 10px 25px rgba(0,0,0,0.1); }
|
||||
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; border-bottom: 1px solid rgba(148, 163, 184, 0.2); padding-bottom: 1rem; }
|
||||
h1 { margin-top: 0; font-size: 2rem; line-height: 1.3; }
|
||||
h1 a { color: inherit; text-decoration: none; }
|
||||
h1 a:hover { color: var(--accent); }
|
||||
.content img { max-width: 100%%; height: auto; border-radius: 8px; }
|
||||
.content pre { background: rgba(0,0,0,0.2); padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
||||
.footer { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(148, 163, 184, 0.2); font-size: 0.85rem; color: var(--muted); text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="meta">
|
||||
<span><strong>%s</strong></span>
|
||||
<span>•</span>
|
||||
<span>%s</span>
|
||||
%s
|
||||
</div>
|
||||
<h1><a href="%s" target="_blank" rel="noopener noreferrer">%s ↗</a></h1>
|
||||
<div class="content">
|
||||
%s
|
||||
</div>
|
||||
<div class="footer">
|
||||
Shared via <a href="/" style="color: var(--accent); text-decoration: none;">RSSPipe Information Gateway</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
e.Title,
|
||||
e.FeedTitle,
|
||||
e.PublishedAt.Format("Jan 02, 2006 15:04"),
|
||||
func() string {
|
||||
if e.Author != "" {
|
||||
return "<span>•</span><span>By " + e.Author + "</span>"
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
e.URL,
|
||||
e.Title,
|
||||
e.Content,
|
||||
)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
type sqlNullHelper struct {
|
||||
String string
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/haopengzhan/rsspipe/internal/auth"
|
||||
)
|
||||
|
||||
func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Compress(5))
|
||||
|
||||
// CORS configuration for local development & PWA
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000", "http://localhost:8080", "*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Public share route (/share/<uuid>.html)
|
||||
r.Get("/share/{uuid}", h.ViewSharedEntry)
|
||||
|
||||
// API Routes
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Public auth endpoints
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/logout", h.Logout)
|
||||
|
||||
// Protected endpoints
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authService.Middleware)
|
||||
|
||||
r.Get("/auth/me", h.GetMe)
|
||||
r.Get("/stats", h.GetStats)
|
||||
|
||||
// Categories
|
||||
r.Get("/categories", h.ListCategories)
|
||||
r.Post("/categories", h.CreateCategory)
|
||||
r.Delete("/categories/{id}", h.DeleteCategory)
|
||||
|
||||
// Feeds
|
||||
r.Get("/feeds", h.ListFeeds)
|
||||
r.Post("/feeds", h.CreateFeed)
|
||||
r.Put("/feeds/{id}", h.UpdateFeed)
|
||||
r.Delete("/feeds/{id}", h.DeleteFeed)
|
||||
r.Post("/feeds/{id}/refresh", h.RefreshFeed)
|
||||
r.Post("/feeds/refresh-all", h.RefreshAllFeeds)
|
||||
|
||||
// Entries
|
||||
r.Get("/entries", h.ListEntries)
|
||||
r.Put("/entries/{id}/read", h.UpdateEntryRead)
|
||||
r.Put("/entries/{id}/star", h.UpdateEntryStar)
|
||||
r.Post("/entries/mark-all-read", h.MarkAllRead)
|
||||
|
||||
// OPML
|
||||
r.Post("/opml/import", h.ImportOPML)
|
||||
r.Get("/opml/export", h.ExportOPML)
|
||||
})
|
||||
})
|
||||
|
||||
// Static Files / Single Page Application fallback
|
||||
if staticDir != "" {
|
||||
if _, err := os.Stat(staticDir); err == nil {
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
path := filepath.Join(staticDir, req.URL.Path)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) && !strings.HasPrefix(req.URL.Path, "/api") && !strings.HasPrefix(req.URL.Path, "/share") {
|
||||
http.ServeFile(w, req, filepath.Join(staticDir, "index.html"))
|
||||
return
|
||||
}
|
||||
fileServer.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/haopengzhan/rsspipe/internal/config"
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserContextKey contextKey = "user"
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
db *db.DB
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewAuthService(database *db.DB, cfg *config.Config) *AuthService {
|
||||
return &AuthService{
|
||||
db: database,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) BootstrapAdmin() error {
|
||||
count, err := s.db.CountUsers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count users: %w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(s.config.AdminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash admin password: %w", err)
|
||||
}
|
||||
_, err = s.db.CreateUser(s.config.AdminUsername, string(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bootstrap admin user: %w", err)
|
||||
}
|
||||
fmt.Printf("==> Initialized Admin user: '%s'\n", s.config.AdminUsername)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Authenticate(username, password string) (string, error) {
|
||||
u, err := s.db.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return "", errors.New("invalid username or password")
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
UserID: u.ID,
|
||||
Username: u.Username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "rsspipe",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(s.config.JWTSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
func (s *AuthService) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var tokenStr string
|
||||
|
||||
// 1. Check Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
tokenStr = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Cookie
|
||||
if tokenStr == "" {
|
||||
if cookie, err := r.Cookie("rsspipe_token"); err == nil {
|
||||
tokenStr = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
if tokenStr == "" {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := s.ValidateToken(tokenStr)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DBPath string
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
JWTSecret string
|
||||
CrawlerIntervalMinutes int
|
||||
CrawlerWorkers int
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
dbPath := os.Getenv("DB_PATH")
|
||||
if dbPath == "" {
|
||||
dbPath = "./data/rsspipe.db"
|
||||
}
|
||||
|
||||
adminUser := os.Getenv("ADMIN_USERNAME")
|
||||
if adminUser == "" {
|
||||
adminUser = "admin"
|
||||
}
|
||||
|
||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminPass == "" {
|
||||
adminPass = "admin123456"
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = "rsspipe-super-secret-jwt-key-2026"
|
||||
}
|
||||
|
||||
intervalMin := 15
|
||||
if intervalStr := os.Getenv("CRAWLER_INTERVAL_MINUTES"); intervalStr != "" {
|
||||
if v, err := strconv.Atoi(intervalStr); err == nil && v > 0 {
|
||||
intervalMin = v
|
||||
}
|
||||
}
|
||||
|
||||
workers := 10
|
||||
if workerStr := os.Getenv("CRAWLER_WORKERS"); workerStr != "" {
|
||||
if v, err := strconv.Atoi(workerStr); err == nil && v > 0 {
|
||||
workers = v
|
||||
}
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DBPath: dbPath,
|
||||
AdminUsername: adminUser,
|
||||
AdminPassword: adminPass,
|
||||
JWTSecret: jwtSecret,
|
||||
CrawlerIntervalMinutes: intervalMin,
|
||||
CrawlerWorkers: workers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package crawler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/db"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
"github.com/haopengzhan/rsspipe/internal/parser"
|
||||
)
|
||||
|
||||
type Crawler struct {
|
||||
db *db.DB
|
||||
parser *parser.Parser
|
||||
intervalMinutes int
|
||||
workerCount int
|
||||
isRefreshing bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCrawler(database *db.DB, intervalMinutes, workers int) *Crawler {
|
||||
return &Crawler{
|
||||
db: database,
|
||||
parser: parser.NewParser(),
|
||||
intervalMinutes: intervalMinutes,
|
||||
workerCount: workers,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Crawler) Start(ctx context.Context) {
|
||||
log.Printf("==> Starting RSS background crawler scheduler (Workers: %d)\n", c.workerCount)
|
||||
|
||||
// Run initial fetch asynchronously after 2 seconds startup
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
c.RefreshAll(context.Background())
|
||||
}()
|
||||
|
||||
// Check due feeds every 45 seconds
|
||||
ticker := time.NewTicker(45 * time.Second)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.RefreshDue(context.Background())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RefreshDue checks all feeds and refreshes only those whose refresh interval has elapsed
|
||||
func (c *Crawler) RefreshDue(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
if c.isRefreshing {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.isRefreshing = true
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.isRefreshing = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
feeds, err := c.db.ListFeeds()
|
||||
if err != nil {
|
||||
log.Printf("Crawler error listing feeds: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var dueFeeds []models.Feed
|
||||
|
||||
for _, f := range feeds {
|
||||
interval := f.RefreshIntervalMinutes
|
||||
if interval <= 0 {
|
||||
interval = 30
|
||||
}
|
||||
if f.LastFetchedAt == nil || now.Sub(*f.LastFetchedAt) >= time.Duration(interval)*time.Minute {
|
||||
dueFeeds = append(dueFeeds, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dueFeeds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Crawler: Refreshing %d due feeds (out of %d total)...\n", len(dueFeeds), len(feeds))
|
||||
c.runWorkerPool(ctx, dueFeeds)
|
||||
}
|
||||
|
||||
// RefreshAll refreshes all feeds unconditionally (e.g. on manual trigger)
|
||||
func (c *Crawler) RefreshAll(ctx context.Context) {
|
||||
c.mu.Lock()
|
||||
if c.isRefreshing {
|
||||
c.mu.Unlock()
|
||||
log.Println("Crawler: RefreshAll already in progress, skipping")
|
||||
return
|
||||
}
|
||||
c.isRefreshing = true
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.isRefreshing = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
feeds, err := c.db.ListFeeds()
|
||||
if err != nil {
|
||||
log.Printf("Crawler error listing feeds: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(feeds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Crawler: Refreshing all %d feeds...\n", len(feeds))
|
||||
c.runWorkerPool(ctx, feeds)
|
||||
}
|
||||
|
||||
func (c *Crawler) runWorkerPool(ctx context.Context, feeds []models.Feed) {
|
||||
jobs := make(chan models.Feed, len(feeds))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < c.workerCount; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for f := range jobs {
|
||||
c.fetchSingleFeed(ctx, f)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, f := range feeds {
|
||||
jobs <- f
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
wg.Wait()
|
||||
log.Printf("Crawler: Completed refresh of %d feeds.\n", len(feeds))
|
||||
}
|
||||
|
||||
func (c *Crawler) RefreshSingle(ctx context.Context, feedID string) error {
|
||||
f, err := c.db.GetFeed(feedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feed not found: %w", err)
|
||||
}
|
||||
return c.fetchSingleFeed(ctx, *f)
|
||||
}
|
||||
|
||||
func (c *Crawler) fetchSingleFeed(ctx context.Context, f models.Feed) error {
|
||||
timeout := time.Duration(f.TimeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
feedCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := c.parser.FetchFeed(feedCtx, f.FeedURL, f.ETag, f.LastModified)
|
||||
if err != nil {
|
||||
log.Printf("Crawler error fetching [%s]: %v\n", f.Title, err)
|
||||
_ = c.db.UpdateFeedFetchStatus(f.ID, f.ETag, f.LastModified, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
if res.NotModified {
|
||||
_ = c.db.UpdateFeedFetchStatus(f.ID, f.ETag, f.LastModified, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upsert entries
|
||||
inserted, err := c.db.UpsertFeedEntries(f.ID, res.Entries)
|
||||
if err != nil {
|
||||
log.Printf("Crawler error upserting entries for [%s]: %v\n", f.Title, err)
|
||||
} else if inserted > 0 {
|
||||
log.Printf("Crawler [%s]: inserted %d new entries\n", f.Title, inserted)
|
||||
}
|
||||
|
||||
_ = c.db.UpdateFeedFetchStatus(f.ID, res.ETag, res.LastModified, "")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
func InitDB(dbPath string) (*DB, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open sqlite db: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode = WAL;",
|
||||
"PRAGMA busy_timeout = 5000;",
|
||||
"PRAGMA synchronous = NORMAL;",
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
return nil, fmt.Errorf("failed to set pragma %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
database := &DB{DB: db}
|
||||
if err := database.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feeds (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
feed_url TEXT UNIQUE NOT NULL,
|
||||
site_url TEXT,
|
||||
category_id TEXT,
|
||||
is_virtual_digest BOOLEAN DEFAULT 0,
|
||||
refresh_interval_minutes INTEGER DEFAULT 30,
|
||||
timeout_seconds INTEGER DEFAULT 10,
|
||||
etag TEXT DEFAULT '',
|
||||
last_modified TEXT DEFAULT '',
|
||||
last_fetched_at DATETIME,
|
||||
fetch_error TEXT DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feed_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
feed_id TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
author TEXT DEFAULT '',
|
||||
content TEXT NOT NULL,
|
||||
raw_content TEXT DEFAULT '',
|
||||
published_at DATETIME NOT NULL,
|
||||
is_read BOOLEAN DEFAULT 0,
|
||||
is_starred BOOLEAN DEFAULT 0,
|
||||
share_uuid TEXT UNIQUE,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY(feed_id) REFERENCES feeds(id) ON DELETE CASCADE,
|
||||
UNIQUE(feed_id, guid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_feed_read ON feed_entries(feed_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_published ON feed_entries(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_is_read ON feed_entries(is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON feed_entries(is_starred);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_share_uuid ON feed_entries(share_uuid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
trigger_type TEXT NOT NULL,
|
||||
trigger_config TEXT DEFAULT '{}',
|
||||
conditions TEXT DEFAULT '[]',
|
||||
actions TEXT DEFAULT '[]',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
// User operations
|
||||
func (d *DB) CountUsers() (int, error) {
|
||||
var count int
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
|
||||
var u models.User
|
||||
err := d.QueryRow(
|
||||
"SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = ?",
|
||||
username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateUser(username, passwordHash string) (*models.User, error) {
|
||||
now := time.Now().UTC()
|
||||
u := &models.User{
|
||||
ID: uuid.New().String(),
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err := d.Exec(
|
||||
"INSERT INTO users (id, username, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
u.ID, u.Username, u.PasswordHash, u.CreatedAt, u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Category operations
|
||||
func (d *DB) ListCategories() ([]models.Category, error) {
|
||||
query := `
|
||||
SELECT c.id, c.title, c.parent_id, c.sort_order, c.created_at, COUNT(f.id) as feed_count
|
||||
FROM categories c
|
||||
LEFT JOIN feeds f ON f.category_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.sort_order ASC, c.title ASC
|
||||
`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []models.Category
|
||||
for rows.Next() {
|
||||
var c models.Category
|
||||
var parentID sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt, &c.FeedCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentID.Valid {
|
||||
p := parentID.String
|
||||
c.ParentID = &p
|
||||
}
|
||||
categories = append(categories, c)
|
||||
}
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetCategoryByTitle(title string) (*models.Category, error) {
|
||||
var c models.Category
|
||||
var parentID sql.NullString
|
||||
err := d.QueryRow("SELECT id, title, parent_id, sort_order, created_at FROM categories WHERE title = ?", title).
|
||||
Scan(&c.ID, &c.Title, &parentID, &c.SortOrder, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentID.Valid {
|
||||
p := parentID.String
|
||||
c.ParentID = &p
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateCategory(title string, parentID *string) (*models.Category, error) {
|
||||
c := &models.Category{
|
||||
ID: uuid.New().String(),
|
||||
Title: title,
|
||||
ParentID: parentID,
|
||||
SortOrder: 0,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
var pID interface{}
|
||||
if parentID != nil {
|
||||
pID = *parentID
|
||||
}
|
||||
_, err := d.Exec("INSERT INTO categories (id, title, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
c.ID, c.Title, pID, c.SortOrder, c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteCategory(id string) error {
|
||||
_, err := d.Exec("DELETE FROM categories WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed operations
|
||||
func (d *DB) ListFeeds() ([]models.Feed, error) {
|
||||
query := `
|
||||
SELECT
|
||||
f.id, f.title, f.feed_url, f.site_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,
|
||||
COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count,
|
||||
COUNT(e.id) as total_count
|
||||
FROM feeds f
|
||||
LEFT JOIN categories c ON c.id = f.category_id
|
||||
LEFT JOIN feed_entries e ON e.feed_id = f.id
|
||||
GROUP BY f.id
|
||||
ORDER BY f.title ASC
|
||||
`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var feeds []models.Feed
|
||||
for rows.Next() {
|
||||
var f models.Feed
|
||||
var catID, siteURL, etag, lastMod, fetchErr sql.NullString
|
||||
var lastFetched sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID, &f.CategoryTitle,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
cid := catID.String
|
||||
f.CategoryID = &cid
|
||||
}
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if etag.Valid {
|
||||
f.ETag = etag.String
|
||||
}
|
||||
if lastMod.Valid {
|
||||
f.LastModified = lastMod.String
|
||||
}
|
||||
if lastFetched.Valid {
|
||||
t := lastFetched.Time
|
||||
f.LastFetchedAt = &t
|
||||
}
|
||||
if fetchErr.Valid {
|
||||
f.FetchError = fetchErr.String
|
||||
}
|
||||
feeds = append(feeds, f)
|
||||
}
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetFeed(id string) (*models.Feed, error) {
|
||||
query := `
|
||||
SELECT
|
||||
f.id, f.title, f.feed_url, f.site_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,
|
||||
COALESCE(SUM(CASE WHEN e.is_read = 0 THEN 1 ELSE 0 END), 0) as unread_count,
|
||||
COUNT(e.id) as total_count
|
||||
FROM feeds f
|
||||
LEFT JOIN categories c ON c.id = f.category_id
|
||||
LEFT JOIN feed_entries e ON e.feed_id = f.id
|
||||
WHERE f.id = ?
|
||||
GROUP BY f.id
|
||||
`
|
||||
var f models.Feed
|
||||
var catID, siteURL, 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.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds,
|
||||
&etag, &lastMod, &lastFetched, &fetchErr, &f.CreatedAt, &f.UnreadCount, &f.TotalCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
cid := catID.String
|
||||
f.CategoryID = &cid
|
||||
}
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
if etag.Valid {
|
||||
f.ETag = etag.String
|
||||
}
|
||||
if lastMod.Valid {
|
||||
f.LastModified = lastMod.String
|
||||
}
|
||||
if lastFetched.Valid {
|
||||
t := lastFetched.Time
|
||||
f.LastFetchedAt = &t
|
||||
}
|
||||
if fetchErr.Valid {
|
||||
f.FetchError = fetchErr.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetFeedByURL(feedURL string) (*models.Feed, error) {
|
||||
var f models.Feed
|
||||
var catID, siteURL, 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 = ?",
|
||||
feedURL,
|
||||
).Scan(
|
||||
&f.ID, &f.Title, &f.FeedURL, &siteURL, &catID,
|
||||
&f.IsVirtualDigest, &f.RefreshIntervalMinutes, &f.TimeoutSeconds, &etag, &lastMod,
|
||||
&lastFetched, &fetchErr, &f.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if catID.Valid {
|
||||
cid := catID.String
|
||||
f.CategoryID = &cid
|
||||
}
|
||||
if siteURL.Valid {
|
||||
f.SiteURL = siteURL.String
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateFeed(title, feedURL, siteURL string, categoryID *string) (*models.Feed, error) {
|
||||
now := time.Now().UTC()
|
||||
f := &models.Feed{
|
||||
ID: uuid.New().String(),
|
||||
Title: title,
|
||||
FeedURL: feedURL,
|
||||
SiteURL: siteURL,
|
||||
CategoryID: categoryID,
|
||||
RefreshIntervalMinutes: 30,
|
||||
TimeoutSeconds: 10,
|
||||
CreatedAt: now,
|
||||
}
|
||||
var catID interface{}
|
||||
if categoryID != nil {
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (d *DB) UpdateFeed(id string, req models.UpdateFeedRequest) error {
|
||||
feed, err := d.GetFeed(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := feed.Title
|
||||
if req.Title != nil && *req.Title != "" {
|
||||
title = *req.Title
|
||||
}
|
||||
|
||||
catID := feed.CategoryID
|
||||
if req.CategoryID != nil {
|
||||
if *req.CategoryID == "" {
|
||||
catID = nil
|
||||
} else {
|
||||
catID = req.CategoryID
|
||||
}
|
||||
}
|
||||
|
||||
interval := feed.RefreshIntervalMinutes
|
||||
if req.RefreshIntervalMinutes != nil && *req.RefreshIntervalMinutes > 0 {
|
||||
interval = *req.RefreshIntervalMinutes
|
||||
}
|
||||
|
||||
timeout := feed.TimeoutSeconds
|
||||
if req.TimeoutSeconds != nil && *req.TimeoutSeconds > 0 {
|
||||
timeout = *req.TimeoutSeconds
|
||||
}
|
||||
|
||||
var cID interface{}
|
||||
if catID != nil {
|
||||
cID = *catID
|
||||
}
|
||||
|
||||
_, err = d.Exec(`
|
||||
UPDATE feeds
|
||||
SET title = ?, category_id = ?, refresh_interval_minutes = ?, timeout_seconds = ?
|
||||
WHERE id = ?
|
||||
`, title, cID, interval, timeout, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateFeedFetchStatus(id string, etag, lastModified, fetchErr string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := d.Exec(
|
||||
"UPDATE feeds SET etag = ?, last_modified = ?, fetch_error = ?, last_fetched_at = ? WHERE id = ?",
|
||||
etag, lastModified, fetchErr, now, id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteFeed(id string) error {
|
||||
_, err := d.Exec("DELETE FROM feeds WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feed Entries operations
|
||||
func (d *DB) UpsertFeedEntries(feedID string, entries []models.FeedEntry) (int, error) {
|
||||
if len(entries) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
stmt, err := d.Prepare(`
|
||||
INSERT INTO feed_entries (
|
||||
id, feed_id, guid, url, title, author, content, raw_content, published_at, is_read, is_starred, metadata, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
|
||||
ON CONFLICT(feed_id, guid) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
content = CASE WHEN feed_entries.content = '' THEN excluded.content ELSE feed_entries.content END,
|
||||
url = excluded.url,
|
||||
author = excluded.author
|
||||
`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
insertedCount := 0
|
||||
for _, e := range entries {
|
||||
id := uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
pubAt := e.PublishedAt
|
||||
if pubAt.IsZero() {
|
||||
pubAt = now
|
||||
}
|
||||
metaBytes, _ := json.Marshal(e.Metadata)
|
||||
if len(metaBytes) == 0 {
|
||||
metaBytes = []byte("{}")
|
||||
}
|
||||
|
||||
res, err := stmt.Exec(
|
||||
id, feedID, e.GUID, e.URL, e.Title, e.Author, e.Content, e.RawContent, pubAt, string(metaBytes), now,
|
||||
)
|
||||
if err == nil {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
insertedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return insertedCount, nil
|
||||
}
|
||||
|
||||
type EntryFilter struct {
|
||||
View string // "inbox" (unread), "starred", "all"
|
||||
FeedID string
|
||||
CategoryID string
|
||||
Search string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func (d *DB) ListEntries(f EntryFilter) ([]models.FeedEntry, int, error) {
|
||||
where := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
|
||||
if f.View == "inbox" {
|
||||
where = append(where, "e.is_read = 0")
|
||||
} else if f.View == "starred" {
|
||||
where = append(where, "e.is_starred = 1")
|
||||
}
|
||||
|
||||
if f.FeedID != "" {
|
||||
where = append(where, "e.feed_id = ?")
|
||||
args = append(args, f.FeedID)
|
||||
}
|
||||
|
||||
if f.CategoryID != "" {
|
||||
where = append(where, "f.category_id = ?")
|
||||
args = append(args, f.CategoryID)
|
||||
}
|
||||
|
||||
if f.Search != "" {
|
||||
searchTerms := strings.Fields(f.Search)
|
||||
for _, term := range searchTerms {
|
||||
lower := strings.ToLower(term)
|
||||
if strings.HasPrefix(lower, "is:unread") {
|
||||
where = append(where, "e.is_read = 0")
|
||||
} else if strings.HasPrefix(lower, "is:read") {
|
||||
where = append(where, "e.is_read = 1")
|
||||
} else if strings.HasPrefix(lower, "is:starred") {
|
||||
where = append(where, "e.is_starred = 1")
|
||||
} else if strings.HasPrefix(lower, "feed:") {
|
||||
feedVal := strings.TrimPrefix(term, "feed:")
|
||||
where = append(where, "(f.title LIKE ? OR f.feed_url LIKE ?)")
|
||||
args = append(args, "%"+feedVal+"%", "%"+feedVal+"%")
|
||||
} else {
|
||||
where = append(where, "(e.title LIKE ? OR e.content LIKE ? OR e.author LIKE ?)")
|
||||
args = append(args, "%"+term+"%", "%"+term+"%", "%"+term+"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
whereClause := strings.Join(where, " AND ")
|
||||
|
||||
// Count total
|
||||
countQuery := fmt.Sprintf(`
|
||||
SELECT COUNT(*)
|
||||
FROM feed_entries e
|
||||
JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE %s
|
||||
`, whereClause)
|
||||
var total int
|
||||
if err := d.QueryRow(countQuery, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
|
||||
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
|
||||
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
|
||||
FROM feed_entries e
|
||||
JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE %s
|
||||
ORDER BY e.published_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, whereClause)
|
||||
|
||||
queryArgs := append(args, limit, offset)
|
||||
rows, err := d.Query(query, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []models.FeedEntry
|
||||
for rows.Next() {
|
||||
var e models.FeedEntry
|
||||
var author, rawContent, metaStr sql.NullString
|
||||
var shareUUID sql.NullString
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL,
|
||||
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
|
||||
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if author.Valid {
|
||||
e.Author = author.String
|
||||
}
|
||||
if rawContent.Valid {
|
||||
e.RawContent = rawContent.String
|
||||
}
|
||||
if shareUUID.Valid {
|
||||
s := shareUUID.String
|
||||
e.ShareUUID = &s
|
||||
}
|
||||
e.Metadata = make(map[string]interface{})
|
||||
if metaStr.Valid && metaStr.String != "" {
|
||||
_ = json.Unmarshal([]byte(metaStr.String), &e.Metadata)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return entries, total, nil
|
||||
}
|
||||
|
||||
func (d *DB) UpdateEntryRead(id string, isRead bool) error {
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_read = ? WHERE id = ?", isRead, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateEntryStarred(id string, isStarred bool) error {
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_starred = ? WHERE id = ?", isStarred, id)
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
return err
|
||||
}
|
||||
if categoryID != nil && *categoryID != "" {
|
||||
_, err := d.Exec(`
|
||||
UPDATE feed_entries
|
||||
SET is_read = 1
|
||||
WHERE feed_id IN (SELECT id FROM feeds WHERE category_id = ?)
|
||||
`, *categoryID)
|
||||
return err
|
||||
}
|
||||
_, err := d.Exec("UPDATE feed_entries SET is_read = 1")
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetStats() (*models.Stats, error) {
|
||||
var s models.Stats
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&s.TotalFeeds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_read = 0").Scan(&s.TotalUnread)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM feed_entries WHERE is_starred = 1").Scan(&s.TotalStarred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow("SELECT COUNT(*) FROM categories").Scan(&s.TotalCategories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
func TestDatabaseOperations(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "rsspipe-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
database, err := InitDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// 1. Test User
|
||||
u, err := database.CreateUser("admin", "$2a$10$dummyhashvalue")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
if u.Username != "admin" {
|
||||
t.Errorf("expected username admin, got %s", u.Username)
|
||||
}
|
||||
|
||||
// 2. Test Category
|
||||
cat, err := database.CreateCategory("Tech News", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCategory failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. Test Feed
|
||||
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)
|
||||
}
|
||||
|
||||
// 4. Test Upsert Feed Entries
|
||||
entries := []models.FeedEntry{
|
||||
{
|
||||
GUID: "item-1",
|
||||
URL: "https://example.com/1",
|
||||
Title: "Post One",
|
||||
Author: "Alice",
|
||||
Content: "<p>Content 1</p>",
|
||||
PublishedAt: time.Now().Add(-1 * time.Hour),
|
||||
},
|
||||
{
|
||||
GUID: "item-2",
|
||||
URL: "https://example.com/2",
|
||||
Title: "Post Two",
|
||||
Author: "Bob",
|
||||
Content: "<p>Content 2</p>",
|
||||
PublishedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
inserted, err := database.UpsertFeedEntries(feed.ID, entries)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertFeedEntries failed: %v", err)
|
||||
}
|
||||
if inserted != 2 {
|
||||
t.Fatalf("expected 2 inserted entries, got %d", inserted)
|
||||
}
|
||||
|
||||
// 5. List entries
|
||||
list, total, err := database.ListEntries(EntryFilter{View: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListEntries failed: %v", err)
|
||||
}
|
||||
if total != 2 || len(list) != 2 {
|
||||
t.Errorf("expected 2 entries, got total %d, len %d", total, len(list))
|
||||
}
|
||||
|
||||
// 6. Update Read & Star
|
||||
firstID := list[0].ID
|
||||
if err := database.UpdateEntryRead(firstID, true); err != nil {
|
||||
t.Fatalf("UpdateEntryRead failed: %v", err)
|
||||
}
|
||||
if err := database.UpdateEntryStarred(firstID, true); err != nil {
|
||||
t.Fatalf("UpdateEntryStarred failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Verify Stats
|
||||
stats, err := database.GetStats()
|
||||
if err != nil {
|
||||
t.Fatalf("GetStats failed: %v", err)
|
||||
}
|
||||
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 {
|
||||
t.Errorf("unexpected stats: %+v", stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents the single admin user
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Category represents a folder/tag grouping for feeds
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
FeedCount int `json:"feed_count,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Feed represents a subscribed RSS/Atom/JSON feed
|
||||
type Feed struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
FeedURL string `json:"feed_url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
CategoryTitle string `json:"category_title,omitempty"`
|
||||
IsVirtualDigest bool `json:"is_virtual_digest"`
|
||||
RefreshIntervalMinutes int `json:"refresh_interval_minutes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"last_modified,omitempty"`
|
||||
LastFetchedAt *time.Time `json:"last_fetched_at,omitempty"`
|
||||
FetchError string `json:"fetch_error,omitempty"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FeedEntry represents an individual article or digest entry
|
||||
type FeedEntry struct {
|
||||
ID string `json:"id"`
|
||||
FeedID string `json:"feed_id"`
|
||||
FeedTitle string `json:"feed_title"`
|
||||
FeedSiteURL string `json:"feed_site_url,omitempty"`
|
||||
GUID string `json:"guid"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Content string `json:"content"`
|
||||
RawContent string `json:"raw_content,omitempty"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
IsRead bool `json:"is_read"`
|
||||
IsStarred bool `json:"is_starred"`
|
||||
ShareUUID *string `json:"share_uuid,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Stats represents dashboard aggregate counts
|
||||
type Stats struct {
|
||||
TotalFeeds int `json:"total_feeds"`
|
||||
TotalUnread int `json:"total_unread"`
|
||||
TotalStarred int `json:"total_starred"`
|
||||
TotalCategories int `json:"total_categories"`
|
||||
}
|
||||
|
||||
// LoginRequest is the body for POST /api/auth/login
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse is the response returned on successful auth
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
// CreateFeedRequest is the body for POST /api/feeds
|
||||
type CreateFeedRequest struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateFeedRequest is the body for PUT /api/feeds/{id}
|
||||
type UpdateFeedRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
RefreshIntervalMinutes *int `json:"refresh_interval_minutes,omitempty"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateEntryReadRequest
|
||||
type UpdateEntryReadRequest struct {
|
||||
IsRead bool `json:"is_read"`
|
||||
}
|
||||
|
||||
// UpdateEntryStarRequest
|
||||
type UpdateEntryStarRequest struct {
|
||||
IsStarred bool `json:"is_starred"`
|
||||
}
|
||||
|
||||
// MarkAllReadRequest
|
||||
type MarkAllReadRequest struct {
|
||||
FeedID *string `json:"feed_id,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
Before *string `json:"before,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
type OPML struct {
|
||||
XMLName xml.Name `xml:"opml"`
|
||||
Version string `xml:"version,attr"`
|
||||
Head Head `xml:"head"`
|
||||
Body Body `xml:"body"`
|
||||
}
|
||||
|
||||
type Head struct {
|
||||
Title string `xml:"title"`
|
||||
DateCreated string `xml:"dateCreated,omitempty"`
|
||||
DateModified string `xml:"dateModified,omitempty"`
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Outlines []Outline `xml:"outline"`
|
||||
}
|
||||
|
||||
type Outline struct {
|
||||
Text string `xml:"text,attr"`
|
||||
Title string `xml:"title,attr,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty"`
|
||||
XMLURL string `xml:"xmlUrl,attr,omitempty"`
|
||||
HTMLURL string `xml:"htmlUrl,attr,omitempty"`
|
||||
Outlines []Outline `xml:"outline,omitempty"`
|
||||
}
|
||||
|
||||
type ImportedFeed struct {
|
||||
Title string
|
||||
FeedURL string
|
||||
SiteURL string
|
||||
CategoryTitle string
|
||||
}
|
||||
|
||||
func ParseOPML(data []byte) ([]ImportedFeed, error) {
|
||||
var doc OPML
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OPML XML: %w", err)
|
||||
}
|
||||
|
||||
var feeds []ImportedFeed
|
||||
for _, topOutline := range doc.Body.Outlines {
|
||||
if topOutline.XMLURL != "" {
|
||||
// Top-level feed without category
|
||||
title := topOutline.Title
|
||||
if title == "" {
|
||||
title = topOutline.Text
|
||||
}
|
||||
feeds = append(feeds, ImportedFeed{
|
||||
Title: title,
|
||||
FeedURL: strings.TrimSpace(topOutline.XMLURL),
|
||||
SiteURL: strings.TrimSpace(topOutline.HTMLURL),
|
||||
CategoryTitle: "",
|
||||
})
|
||||
} else if len(topOutline.Outlines) > 0 {
|
||||
// Category folder containing feeds
|
||||
categoryTitle := topOutline.Title
|
||||
if categoryTitle == "" {
|
||||
categoryTitle = topOutline.Text
|
||||
}
|
||||
for _, child := range topOutline.Outlines {
|
||||
if child.XMLURL != "" {
|
||||
title := child.Title
|
||||
if title == "" {
|
||||
title = child.Text
|
||||
}
|
||||
feeds = append(feeds, ImportedFeed{
|
||||
Title: title,
|
||||
FeedURL: strings.TrimSpace(child.XMLURL),
|
||||
SiteURL: strings.TrimSpace(child.HTMLURL),
|
||||
CategoryTitle: strings.TrimSpace(categoryTitle),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feeds, nil
|
||||
}
|
||||
|
||||
func GenerateOPML(feeds []models.Feed, categories []models.Category) ([]byte, error) {
|
||||
doc := OPML{
|
||||
Version: "2.0",
|
||||
Head: Head{
|
||||
Title: "RSSPipe Subscriptions",
|
||||
DateCreated: time.Now().UTC().Format(time.RFC1123),
|
||||
DateModified: time.Now().UTC().Format(time.RFC1123),
|
||||
},
|
||||
Body: Body{
|
||||
Outlines: make([]Outline, 0),
|
||||
},
|
||||
}
|
||||
|
||||
// Map category ID to slice of feeds
|
||||
catFeedMap := make(map[string][]models.Feed)
|
||||
var uncategorized []models.Feed
|
||||
|
||||
for _, f := range feeds {
|
||||
if f.CategoryID != nil && *f.CategoryID != "" {
|
||||
catFeedMap[*f.CategoryID] = append(catFeedMap[*f.CategoryID], f)
|
||||
} else {
|
||||
uncategorized = append(uncategorized, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Add categorized feeds
|
||||
for _, c := range categories {
|
||||
feedList, exists := catFeedMap[c.ID]
|
||||
if !exists || len(feedList) == 0 {
|
||||
continue
|
||||
}
|
||||
catOutline := Outline{
|
||||
Text: c.Title,
|
||||
Title: c.Title,
|
||||
Outlines: make([]Outline, 0, len(feedList)),
|
||||
}
|
||||
for _, f := range feedList {
|
||||
catOutline.Outlines = append(catOutline.Outlines, Outline{
|
||||
Text: f.Title,
|
||||
Title: f.Title,
|
||||
Type: "rss",
|
||||
XMLURL: f.FeedURL,
|
||||
HTMLURL: f.SiteURL,
|
||||
})
|
||||
}
|
||||
doc.Body.Outlines = append(doc.Body.Outlines, catOutline)
|
||||
}
|
||||
|
||||
// Add uncategorized feeds
|
||||
for _, f := range uncategorized {
|
||||
doc.Body.Outlines = append(doc.Body.Outlines, Outline{
|
||||
Text: f.Title,
|
||||
Title: f.Title,
|
||||
Type: "rss",
|
||||
XMLURL: f.FeedURL,
|
||||
HTMLURL: f.SiteURL,
|
||||
})
|
||||
}
|
||||
|
||||
xmlHeader := []byte(xml.Header)
|
||||
data, err := xml.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(xmlHeader, data...), nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package opml
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
func TestParseAndGenerateOPML(t *testing.T) {
|
||||
sampleOPML := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<opml version="2.0">
|
||||
<head>
|
||||
<title>Miniflux Subscriptions</title>
|
||||
</head>
|
||||
<body>
|
||||
<outline text="Technology" title="Technology">
|
||||
<outline type="rss" text="Hacker News" title="Hacker News" xmlUrl="https://news.ycombinator.com/rss" htmlUrl="https://news.ycombinator.com/" />
|
||||
<outline type="rss" text="Go Blog" title="The Go Programming Language" xmlUrl="https://go.dev/blog/feed.atom" htmlUrl="https://go.dev/blog" />
|
||||
</outline>
|
||||
<outline type="rss" text="Ars Technica" title="Ars Technica" xmlUrl="https://feeds.arstechnica.com/arstechnica/index" htmlUrl="https://arstechnica.com" />
|
||||
</body>
|
||||
</opml>`
|
||||
|
||||
feeds, err := ParseOPML([]byte(sampleOPML))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOPML failed: %v", err)
|
||||
}
|
||||
|
||||
if len(feeds) != 3 {
|
||||
t.Fatalf("expected 3 feeds, got %d", len(feeds))
|
||||
}
|
||||
|
||||
// Verify first feed with category
|
||||
if feeds[0].Title != "Hacker News" || feeds[0].FeedURL != "https://news.ycombinator.com/rss" || feeds[0].CategoryTitle != "Technology" {
|
||||
t.Errorf("unexpected feed 0: %+v", feeds[0])
|
||||
}
|
||||
|
||||
// Verify uncategorized feed
|
||||
if feeds[2].Title != "Ars Technica" || feeds[2].FeedURL != "https://feeds.arstechnica.com/arstechnica/index" || feeds[2].CategoryTitle != "" {
|
||||
t.Errorf("unexpected feed 2: %+v", feeds[2])
|
||||
}
|
||||
|
||||
// Test Export Generation
|
||||
catID := "cat-1"
|
||||
categories := []models.Category{
|
||||
{ID: catID, Title: "Technology"},
|
||||
}
|
||||
modelFeeds := []models.Feed{
|
||||
{ID: "f-1", Title: "Hacker News", FeedURL: "https://news.ycombinator.com/rss", SiteURL: "https://news.ycombinator.com/", CategoryID: &catID},
|
||||
{ID: "f-2", Title: "Ars Technica", FeedURL: "https://feeds.arstechnica.com/arstechnica/index", SiteURL: "https://arstechnica.com"},
|
||||
}
|
||||
|
||||
exportedXML, err := GenerateOPML(modelFeeds, categories)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateOPML failed: %v", err)
|
||||
}
|
||||
|
||||
reParsed, err := ParseOPML(exportedXML)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to re-parse exported OPML: %v", err)
|
||||
}
|
||||
|
||||
if len(reParsed) != 2 {
|
||||
t.Fatalf("expected 2 re-parsed feeds, got %d", len(reParsed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
"github.com/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
type FetchResult struct {
|
||||
NotModified bool
|
||||
ETag string
|
||||
LastModified string
|
||||
Title string
|
||||
SiteURL string
|
||||
Entries []models.FeedEntry
|
||||
}
|
||||
|
||||
type Parser struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewParser() *Parser {
|
||||
return &Parser{
|
||||
client: &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) FetchFeed(ctx context.Context, feedURL, etag, lastModified string) (*FetchResult, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", feedURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "RSSPipe/1.0 (+https://github.com/haopengzhan/rsspipe)")
|
||||
req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, application/json;q=0.9, */*;q=0.8")
|
||||
|
||||
if etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
if lastModified != "" {
|
||||
req.Header.Set("If-Modified-Since", lastModified)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotModified {
|
||||
return &FetchResult{NotModified: true}, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("server returned HTTP status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
newETag := resp.Header.Get("ETag")
|
||||
newLastModified := resp.Header.Get("Last-Modified")
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
fp := gofeed.NewParser()
|
||||
feed, err := fp.ParseString(string(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse feed content: %w", err)
|
||||
}
|
||||
|
||||
result := &FetchResult{
|
||||
NotModified: false,
|
||||
ETag: newETag,
|
||||
LastModified: newLastModified,
|
||||
Title: feed.Title,
|
||||
SiteURL: feed.Link,
|
||||
Entries: make([]models.FeedEntry, 0, len(feed.Items)),
|
||||
}
|
||||
|
||||
for _, item := range feed.Items {
|
||||
guid := item.GUID
|
||||
if guid == "" {
|
||||
guid = item.Link
|
||||
}
|
||||
if guid == "" {
|
||||
guid = item.Title
|
||||
}
|
||||
|
||||
pubDate := time.Now().UTC()
|
||||
if item.PublishedParsed != nil {
|
||||
pubDate = item.PublishedParsed.UTC()
|
||||
} else if item.UpdatedParsed != nil {
|
||||
pubDate = item.UpdatedParsed.UTC()
|
||||
}
|
||||
|
||||
content := item.Content
|
||||
if strings.TrimSpace(content) == "" {
|
||||
content = item.Description
|
||||
}
|
||||
|
||||
author := ""
|
||||
if item.Author != nil {
|
||||
author = item.Author.Name
|
||||
}
|
||||
|
||||
entry := models.FeedEntry{
|
||||
GUID: guid,
|
||||
URL: item.Link,
|
||||
Title: item.Title,
|
||||
Author: author,
|
||||
Content: content,
|
||||
RawContent: item.Description,
|
||||
PublishedAt: pubDate,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="description" content="RSSPipe - RSS Driven Information Gateway" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>RSSPipe | Information Gateway</title>
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1802
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "rsspipe-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="100%" stop-color="#0284c7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="24" height="24" rx="6" fill="#0f172a" />
|
||||
<circle cx="6.5" cy="17.5" r="1.5" fill="url(#grad)" />
|
||||
<path d="M5 11.5A7.5 7.5 0 0 1 12.5 19" stroke="url(#grad)" stroke-width="2.2" stroke-linecap="round" />
|
||||
<path d="M5 6.5A12.5 12.5 0 0 1 17.5 19" stroke="url(#grad)" stroke-width="2.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 642 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "RSSPipe",
|
||||
"short_name": "RSSPipe",
|
||||
"description": "RSS-Driven Information Gateway",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.svg",
|
||||
"sizes": "192x192 512x512",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const CACHE_NAME = 'rsspipe-v1';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Let browser fetch normally, can cache static assets if offline
|
||||
if (event.request.method !== 'GET') return;
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
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';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
// App State with localStorage Persistence
|
||||
const [filter, setFilter] = useState<FilterState>(() => {
|
||||
const saved = localStorage.getItem('rsspipe_filter');
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return { view: 'inbox', search: '' };
|
||||
});
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
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';
|
||||
});
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true';
|
||||
});
|
||||
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(() => {
|
||||
return localStorage.getItem('rsspipe_selected_entry_id');
|
||||
});
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [entries, setEntries] = useState<FeedEntry[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(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
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_filter', JSON.stringify(filter));
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_view_mode', viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('rsspipe_sidebar_collapsed', sidebarCollapsed ? 'true' : 'false');
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Check auth session
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
if (!token) {
|
||||
setAuthLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
setCurrentUser(user);
|
||||
} catch {
|
||||
localStorage.removeItem('rsspipe_jwt');
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
|
||||
const handleUnauthorized = () => {
|
||||
setCurrentUser(null);
|
||||
};
|
||||
window.addEventListener('auth:unauthorized', handleUnauthorized);
|
||||
return () => window.removeEventListener('auth:unauthorized', handleUnauthorized);
|
||||
}, []);
|
||||
|
||||
// Load sidebar data (Feeds, Categories, Stats)
|
||||
const loadSidebarData = useCallback(async () => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
const [catList, feedList, statData] = await Promise.all([
|
||||
api.listCategories(),
|
||||
api.listFeeds(),
|
||||
api.getStats(),
|
||||
]);
|
||||
setCategories(catList);
|
||||
setFeeds(feedList);
|
||||
setStats(statData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sidebar data:', err);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// Load entries matching current 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,
|
||||
});
|
||||
setEntries(res.entries);
|
||||
} catch (err) {
|
||||
console.error('Failed to load entries:', err);
|
||||
}
|
||||
}, [currentUser, filter]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}
|
||||
}, [currentUser, loadSidebarData, loadEntries]);
|
||||
|
||||
// Sync focused index with persisted selectedEntryId
|
||||
useEffect(() => {
|
||||
if (entries.length > 0) {
|
||||
if (selectedEntryId) {
|
||||
const idx = entries.findIndex((e) => e.id === selectedEntryId);
|
||||
if (idx >= 0) {
|
||||
setFocusedIndex(idx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setFocusedIndex(0);
|
||||
if (entries[0]) {
|
||||
setSelectedEntryId(entries[0].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[0].id);
|
||||
}
|
||||
}
|
||||
}, [entries, selectedEntryId]);
|
||||
|
||||
const handleSelectEntry = (index: number) => {
|
||||
setFocusedIndex(index);
|
||||
if (entries[index]) {
|
||||
setSelectedEntryId(entries[index].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[index].id);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-Refresh & Background Polling (Every 30s + on focus / visibility change)
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}, 30000);
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [currentUser, loadSidebarData, loadEntries]);
|
||||
|
||||
// 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
|
||||
setEntries((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === entry.id ? { ...item, is_read: newStatus } : item
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await api.updateRead(entry.id, newStatus);
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle Star Action with optimistic update
|
||||
const handleToggleStar = async (entry: FeedEntry, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
const newStatus = !entry.is_starred;
|
||||
|
||||
// Optimistic update
|
||||
setEntries((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === entry.id ? { ...item, is_starred: newStatus } : item
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await api.updateStar(entry.id, newStatus);
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark All Read
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
await api.markAllRead(filter.feedId, filter.categoryId);
|
||||
setEntries((prev) => prev.map((item) => ({ ...item, is_read: true })));
|
||||
loadSidebarData();
|
||||
} catch (err) {
|
||||
console.error('Failed to mark all read:', 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setShowAddFeedModal(false);
|
||||
setShowOPMLModal(false);
|
||||
setShowShortcutsModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case '[': // Toggle Sidebar
|
||||
e.preventDefault();
|
||||
setSidebarCollapsed((prev) => !prev);
|
||||
break;
|
||||
|
||||
case 'j':
|
||||
case 'J': // Next item
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
||||
if (entries[next]) {
|
||||
setSelectedEntryId(entries[next].id);
|
||||
localStorage.setItem('rsspipe_selected_entry_id', entries[next].id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'k':
|
||||
case 'K': // Previous item
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.max(prev - 1, 0);
|
||||
if (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
|
||||
if (entries[focusedIndex]?.url) {
|
||||
e.preventDefault();
|
||||
window.open(entries[focusedIndex].url, '_blank');
|
||||
}
|
||||
break;
|
||||
|
||||
case 's':
|
||||
case 'S': // Toggle Star
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleToggleStar(entries[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
case 'M': // Toggle Read / Unread
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
handleToggleRead(entries[focusedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'e':
|
||||
case 'E': // Archive / Mark read and advance
|
||||
if (entries[focusedIndex]) {
|
||||
e.preventDefault();
|
||||
if (!entries[focusedIndex].is_read) {
|
||||
handleToggleRead(entries[focusedIndex]);
|
||||
}
|
||||
setFocusedIndex((prev) => {
|
||||
const next = Math.min(prev + 1, Math.max(0, entries.length - 1));
|
||||
if (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
|
||||
e.preventDefault();
|
||||
handleRefreshAll();
|
||||
break;
|
||||
|
||||
case '1': // Switch to Desktop 3-Split View
|
||||
e.preventDefault();
|
||||
setViewMode('split');
|
||||
break;
|
||||
|
||||
case '2': // Switch to Xiaohongshu Waterfall View
|
||||
e.preventDefault();
|
||||
setViewMode('waterfall');
|
||||
break;
|
||||
|
||||
case '3': // Switch to Settings View
|
||||
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();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [entries, focusedIndex]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div style={{ color: 'var(--text-main)', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
Loading RSSPipe...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginView onLoginSuccess={(user) => setCurrentUser(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
{/* App Header */}
|
||||
<Header
|
||||
search={filter.search}
|
||||
onSearchChange={(val) => 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)}
|
||||
/>
|
||||
|
||||
{/* Main View Mode Content */}
|
||||
{viewMode === 'split' ? (
|
||||
<ThreePaneView
|
||||
filter={filter}
|
||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
stats={stats}
|
||||
entries={entries}
|
||||
selectedIndex={focusedIndex}
|
||||
onSelectEntry={handleSelectEntry}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
) : viewMode === 'waterfall' ? (
|
||||
<WaterfallView
|
||||
filter={filter}
|
||||
onFilterChange={(newF) => setFilter((prev) => ({ ...prev, ...newF }))}
|
||||
categories={categories}
|
||||
entries={entries}
|
||||
onToggleRead={handleToggleRead}
|
||||
onToggleStar={handleToggleStar}
|
||||
/>
|
||||
) : (
|
||||
<SettingsView
|
||||
currentUser={currentUser}
|
||||
feeds={feeds}
|
||||
categories={categories}
|
||||
stats={stats}
|
||||
onRefreshData={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
onOpenAddFeed={() => setShowAddFeedModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showAddFeedModal && (
|
||||
<AddFeedModal
|
||||
categories={categories}
|
||||
onClose={() => setShowAddFeedModal(false)}
|
||||
onFeedAdded={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOPMLModal && (
|
||||
<OPMLModal
|
||||
onClose={() => setShowOPMLModal(false)}
|
||||
onImportComplete={() => {
|
||||
loadSidebarData();
|
||||
loadEntries();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showShortcutsModal && (
|
||||
<KeyboardShortcutsModal onClose={() => setShowShortcutsModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import { X, Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
interface AddFeedModalProps {
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
onFeedAdded: (feed: Feed) => void;
|
||||
}
|
||||
|
||||
export const AddFeedModal: React.FC<AddFeedModalProps> = ({
|
||||
categories,
|
||||
onClose,
|
||||
onFeedAdded,
|
||||
}) => {
|
||||
const [feedUrl, setFeedUrl] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [newCatTitle, setNewCatTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!feedUrl.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let finalCatId: string | undefined = categoryId || undefined;
|
||||
|
||||
// If user typed a new category name
|
||||
if (newCatTitle.trim()) {
|
||||
const newCat = await api.createCategory(newCatTitle.trim());
|
||||
finalCatId = newCat.id;
|
||||
}
|
||||
|
||||
const newFeed = await api.createFeed(
|
||||
feedUrl.trim(),
|
||||
finalCatId,
|
||||
title.trim() || undefined
|
||||
);
|
||||
|
||||
onFeedAdded(newFeed);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add feed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Add New Subscription</h3>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-body">
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Feed or Website URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-input"
|
||||
value={feedUrl}
|
||||
onChange={(e) => setFeedUrl(e.target.value)}
|
||||
placeholder="https://news.ycombinator.com/rss"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Custom Title (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Leave blank to auto-detect from feed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Category / Folder</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">(No Category)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Or Create New Category</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={newCatTitle}
|
||||
onChange={(e) => setNewCatTitle(e.target.value)}
|
||||
placeholder="e.g. AI & Tech"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', marginTop: '1.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={loading || !feedUrl.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" /> Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={16} /> Subscribe
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { FeedEntry } from '../types';
|
||||
import { estimateReadingTime } from '../utils';
|
||||
import {
|
||||
X,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ArticleModalProps {
|
||||
entry: FeedEntry;
|
||||
onClose: () => void;
|
||||
onToggleStar: (e?: React.MouseEvent) => void;
|
||||
onToggleRead: (e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ArticleModal: React.FC<ArticleModalProps> = ({
|
||||
entry,
|
||||
onClose,
|
||||
onToggleStar,
|
||||
onToggleRead,
|
||||
}) => {
|
||||
const shareUrl = `${window.location.origin}/share/${entry.share_uuid || entry.id}.html`;
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (entry.is_read) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
if (scrollHeight <= clientHeight + 40) return;
|
||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
||||
if (scrollProgress >= 0.9) {
|
||||
onToggleRead();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="article-drawer-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="article-drawer-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Header Actions */}
|
||||
<div className="article-drawer-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: entry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={onToggleStar}
|
||||
title={entry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
||||
>
|
||||
<Star
|
||||
size={17}
|
||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: entry.is_read ? 'var(--success)' : undefined,
|
||||
}}
|
||||
onClick={onToggleRead}
|
||||
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
>
|
||||
<CheckCircle2 size={17} />
|
||||
</button>
|
||||
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginLeft: '0.5rem' }}>
|
||||
{entry.feed_title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<a
|
||||
href={shareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
<span>Share</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={entry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>Original Link</span>
|
||||
</a>
|
||||
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Scroll Body */}
|
||||
<div className="article-drawer-scroll" onScroll={handleScroll}>
|
||||
<div className="reader-canvas" style={{ padding: 0 }}>
|
||||
<div className="reader-source-bar">
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||
{entry.feed_title}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={13} />{' '}
|
||||
{new Date(entry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(entry.content || entry.raw_content)}</span>
|
||||
{entry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={13} /> {entry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
{entry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: entry.content || entry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FeedEntry } from '../types';
|
||||
import {
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CompactListViewProps {
|
||||
entries: FeedEntry[];
|
||||
selectedIndex: number;
|
||||
onSelectEntry: (index: number) => void;
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const CompactListView: React.FC<CompactListViewProps> = ({
|
||||
entries,
|
||||
selectedIndex,
|
||||
onSelectEntry,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
}) => {
|
||||
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
||||
const el = rowRefs.current[selectedIndex];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, entries.length]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🎉</div>
|
||||
<h3>Inbox Zero!</h3>
|
||||
<p>No articles to display in this view.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedEntry = entries[selectedIndex] || entries[0];
|
||||
|
||||
return (
|
||||
<div className="compact-layout">
|
||||
{/* Left List Pane (Gmail style) */}
|
||||
<div className="compact-list">
|
||||
{entries.map((entry, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const pubDate = new Date(entry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
// Strip HTML tags for clean 1-line snippet
|
||||
const snippet = (entry.content || entry.raw_content || '')
|
||||
.replace(/<[^>]*>?/gm, '')
|
||||
.substring(0, 120);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
ref={(el) => (rowRefs.current[index] = el)}
|
||||
className={`compact-row ${isSelected ? 'focused' : ''} ${
|
||||
entry.is_read ? 'is-read' : ''
|
||||
}`}
|
||||
onClick={() => onSelectEntry(index)}
|
||||
>
|
||||
{/* Star toggle */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: entry.is_starred ? 'var(--star)' : 'var(--text-muted)',
|
||||
}}
|
||||
onClick={(e) => onToggleStar(entry, e)}
|
||||
title={entry.is_starred ? 'Unstar' : 'Star'}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Mark read toggle */}
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: entry.is_read ? 'var(--success)' : 'var(--unread-badge)',
|
||||
}}
|
||||
onClick={(e) => onToggleRead(entry, e)}
|
||||
title={entry.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
</button>
|
||||
|
||||
{/* Feed Title */}
|
||||
<div className="compact-row-feed" title={entry.feed_title}>
|
||||
{entry.feed_title}
|
||||
</div>
|
||||
|
||||
{/* Article Title & Snippet */}
|
||||
<div className="compact-row-main">
|
||||
<span className="compact-row-title">{entry.title}</span>
|
||||
<span className="compact-row-snippet"> - {snippet}</span>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div className="compact-row-date">{pubDate}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Reading Pane */}
|
||||
{selectedEntry && (
|
||||
<div className="compact-reading-pane">
|
||||
<div className="reading-pane-header">
|
||||
<div className="stream-source-meta">
|
||||
<span className="stream-source-badge">{selectedEntry.feed_title}</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{' '}
|
||||
{new Date(selectedEntry.published_at).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reading-pane-title">
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||
>
|
||||
{selectedEntry.title} ↗
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
{/* Actions Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => onToggleRead(selectedEntry)}
|
||||
>
|
||||
<CheckCircle2 size={15} />
|
||||
<span>{selectedEntry.is_read ? 'Mark as Unread (M)' : 'Mark as Read (M)'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-secondary"
|
||||
style={{ color: selectedEntry.is_starred ? 'var(--star)' : undefined }}
|
||||
onClick={() => onToggleStar(selectedEntry)}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
<span>{selectedEntry.is_starred ? 'Starred (S)' : 'Star (S)'}</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
<span>Open Original (V)</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
>
|
||||
<Share2 size={15} />
|
||||
<span>Public Share</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="reading-pane-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { ViewMode } from '../types';
|
||||
import {
|
||||
Rss,
|
||||
Search,
|
||||
Columns3,
|
||||
LayoutGrid,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
search: string;
|
||||
onSearchChange: (val: string) => void;
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
isRefreshing: boolean;
|
||||
onRefreshAll: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
onOpenShortcuts: () => void;
|
||||
onLogout: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
isRefreshing,
|
||||
onRefreshAll,
|
||||
onOpenAddFeed,
|
||||
onOpenShortcuts,
|
||||
onLogout,
|
||||
sidebarCollapsed,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.key === '/' &&
|
||||
document.activeElement?.tagName !== 'INPUT' &&
|
||||
document.activeElement?.tagName !== 'TEXTAREA'
|
||||
) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
{/* Left: Logo & Sidebar Toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.45rem' }}>
|
||||
{!isMobile && viewMode === 'split' && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onToggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show Sidebar ( [ )' : 'Hide Sidebar ( [ )'}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeft size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="logo-area"
|
||||
onClick={() => onViewModeChange(isMobile ? 'waterfall' : 'split')}
|
||||
>
|
||||
<div style={{ color: 'var(--accent)', display: 'flex', alignItems: 'center' }}>
|
||||
<Rss size={19} />
|
||||
</div>
|
||||
<span>RSSPipe</span>
|
||||
{!isMobile && <span className="logo-badge">Gateway</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Search Bar */}
|
||||
<div className="search-bar-container">
|
||||
<Search size={14} className="search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="search-input"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={isMobile ? 'Search...' : "Search feeds, 'is:unread'..."}
|
||||
/>
|
||||
{!search && !isMobile && <span className="search-shortcut-hint">/</span>}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
{isMobile ? (
|
||||
// Mobile Header Right: 3 Dots Menu Button
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setMobileMenuOpen((prev) => !prev)}
|
||||
title="Menu"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 40 }}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<div className="mobile-menu-dropdown">
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onOpenAddFeed();
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onRefreshAll();
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
<span>Refresh Feeds</span>
|
||||
</button>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onViewModeChange('waterfall');
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={15} />
|
||||
<span>Waterfall Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onViewModeChange('split');
|
||||
}}
|
||||
>
|
||||
<Columns3 size={15} />
|
||||
<span>3-Split View</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onViewModeChange('settings');
|
||||
}}
|
||||
>
|
||||
<Settings size={15} />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border-light)', margin: '2px 0' }} />
|
||||
|
||||
<button
|
||||
className="mobile-menu-item"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
onLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Desktop Header Right Actions
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={onOpenAddFeed}
|
||||
title="Add new subscription (A)"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onRefreshAll}
|
||||
title="Refresh all feeds (R)"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={15} className={isRefreshing ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="view-mode-pill">
|
||||
<button
|
||||
className={`btn-icon ${viewMode === 'split' ? 'active' : ''}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
onClick={() => onViewModeChange('split')}
|
||||
title="Desktop 3-Pane Split View (1)"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-icon ${viewMode === 'waterfall' ? 'active' : ''}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
onClick={() => onViewModeChange('waterfall')}
|
||||
title="Xiaohongshu Waterfall View (2)"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-icon ${viewMode === 'settings' ? 'active' : ''}`}
|
||||
style={{ width: 28, height: 28 }}
|
||||
onClick={() => onViewModeChange('settings')}
|
||||
title="Settings & Subscriptions (3)"
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenShortcuts}
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<HelpCircle size={15} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onLogout}
|
||||
title="Sign Out"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { X, Keyboard } from 'lucide-react';
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const shortcuts = [
|
||||
{ key: 'j / k', desc: 'Navigate to Next / Previous article' },
|
||||
{ key: 'o / Enter', desc: 'Open / Expand selected article' },
|
||||
{ key: 'v', desc: 'Open original link in a new browser tab' },
|
||||
{ key: 's', desc: 'Star / Unstar article' },
|
||||
{ key: 'm', desc: 'Toggle Read / Unread status' },
|
||||
{ key: 'e / Shift+I', desc: 'Archive / Mark Read and advance to next' },
|
||||
{ key: 'Shift + A', desc: 'Mark all items in current view as read' },
|
||||
{ key: '/', desc: 'Focus search bar' },
|
||||
{ key: 'r', desc: 'Refresh all feeds' },
|
||||
{ key: '1', desc: 'Switch to Reeder 4 Minimal 3-Pane Split View' },
|
||||
{ key: '2', desc: 'Switch to Xiaohongshu Waterfall / Masonry View' },
|
||||
{ key: '3', desc: 'Switch to Continuous Stream View' },
|
||||
{ key: 'a', desc: 'Add new subscription modal' },
|
||||
{ key: '?', desc: 'Open keyboard shortcuts cheatsheet' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Keyboard size={20} style={{ color: 'var(--accent)' }} />
|
||||
<h3>Google Reader & Gmail Keyboard Shortcuts</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
SHORTCUT
|
||||
</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
ACTION
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shortcuts.map((sc, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '0.65rem 0.75rem' }}>
|
||||
<kbd
|
||||
style={{
|
||||
background: 'var(--bg-main)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{sc.key}
|
||||
</kbd>
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.75rem', color: 'var(--text-secondary)' }}>
|
||||
{sc.desc}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Press <kbd style={{ padding: '2px 5px', border: '1px solid var(--border)', borderRadius: 3 }}>ESC</kbd> to close any modal.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { User } from '../types';
|
||||
import { Rss, Lock, User as UserIcon, ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoginViewProps {
|
||||
onLoginSuccess: (user: User) => void;
|
||||
}
|
||||
|
||||
export const LoginView: React.FC<LoginViewProps> = ({ onLoginSuccess }) => {
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('admin123456');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.login(username, password);
|
||||
onLoginSuccess(res.user);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
<div style={{ width: 36, height: 36, color: 'var(--accent)' }}>
|
||||
<Rss size={36} />
|
||||
</div>
|
||||
<span>RSSPipe</span>
|
||||
</div>
|
||||
<p className="login-subtitle">RSS-Driven Information Gateway</p>
|
||||
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<UserIcon size={14} /> Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Lock size={14} /> Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
style={{ width: '100%', justifyContent: 'center', padding: '0.75rem', marginTop: '1rem', fontSize: '0.95rem' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" /> Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In <ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: '1.5rem', textAlign: 'center', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Default single-user credentials: <code>admin</code> / <code>admin123456</code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { X, UploadCloud, Download, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface OPMLModalProps {
|
||||
onClose: () => void;
|
||||
onImportComplete: () => void;
|
||||
}
|
||||
|
||||
export const OPMLModal: React.FC<OPMLModalProps> = ({
|
||||
onClose,
|
||||
onImportComplete,
|
||||
}) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{ imported: number; total: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setFile(e.target.files[0]);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
setFile(e.dataTransfer.files[0]);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await api.importOPML(file);
|
||||
setResult({ imported: res.imported_count, total: res.total_parsed });
|
||||
onImportComplete();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to import OPML file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = api.exportOPMLUrl();
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>OPML Migration & Backup</h3>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert-danger">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(52, 211, 153, 0.15)',
|
||||
border: '1px solid var(--success)',
|
||||
color: 'var(--success)',
|
||||
padding: '0.85rem',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
marginBottom: '1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={18} />
|
||||
<span>
|
||||
Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Section */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
Import Subscriptions (from Miniflux / Google Reader / OPML)
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Upload an OPML 2.0 XML file to seamlessly import all your feeds and categories.
|
||||
</p>
|
||||
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: 'var(--bg-main)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".opml,.xml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<UploadCloud
|
||||
size={36}
|
||||
style={{ color: 'var(--accent)', margin: '0 auto 0.5rem auto' }}
|
||||
/>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500 }}>
|
||||
{file ? file.name : 'Click to browse or drag & drop OPML file'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 4 }}>
|
||||
Supports .opml or .xml
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImport}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" /> Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Import {file.name}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1.25rem' }}>
|
||||
<h4 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
Export Subscriptions
|
||||
</h4>
|
||||
<p style={{ fontSize: '0.825rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Download a complete OPML backup of all your subscribed feeds and folders.
|
||||
</p>
|
||||
<button className="btn-secondary" onClick={handleExport}>
|
||||
<Download size={16} /> Export OPML File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,801 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, Stats, User } from '../types';
|
||||
import { api } from '../services/api';
|
||||
import {
|
||||
Rss,
|
||||
Folder,
|
||||
FileSpreadsheet,
|
||||
Shield,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
Download,
|
||||
Loader2,
|
||||
Edit2,
|
||||
AlertCircle,
|
||||
X,
|
||||
Clock,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SettingsViewProps {
|
||||
currentUser: User;
|
||||
feeds: Feed[];
|
||||
categories: Category[];
|
||||
stats: Stats | null;
|
||||
onRefreshData: () => void;
|
||||
onOpenAddFeed: () => void;
|
||||
}
|
||||
|
||||
export const SettingsView: React.FC<SettingsViewProps> = ({
|
||||
currentUser,
|
||||
feeds,
|
||||
categories,
|
||||
stats,
|
||||
onRefreshData,
|
||||
onOpenAddFeed,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'feeds' | 'categories' | 'opml' | 'account'>('feeds');
|
||||
const [feedSearch, setFeedSearch] = useState('');
|
||||
const [newCatTitle, setNewCatTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// Edit Feed Modal State
|
||||
const [editingFeed, setEditingFeed] = useState<Feed | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editCategoryId, setEditCategoryId] = useState<string>('');
|
||||
const [editInterval, setEditInterval] = useState<number>(30);
|
||||
const [editTimeout, setEditTimeout] = useState<number>(10);
|
||||
const [editSaving, setEditSaving] = useState(false);
|
||||
|
||||
// Debug Log Modal State
|
||||
const [debugFeed, setDebugFeed] = useState<Feed | null>(null);
|
||||
|
||||
// Custom Delete Confirmation Modal State
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||
type: 'feed' | 'category';
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
// OPML state
|
||||
const [opmlFile, setOpmlFile] = useState<File | null>(null);
|
||||
const [opmlLoading, setOpmlLoading] = useState(false);
|
||||
const [opmlResult, setOpmlResult] = useState<string | null>(null);
|
||||
|
||||
// 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 handleRefreshFeed = async (feedId: string) => {
|
||||
try {
|
||||
await api.refreshFeed(feedId);
|
||||
onRefreshData();
|
||||
setMessage('Feed refreshed successfully.');
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to refresh feed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
try {
|
||||
if (deleteConfirm.type === 'feed') {
|
||||
await api.deleteFeed(deleteConfirm.id);
|
||||
setMessage(`Unsubscribed from "${deleteConfirm.title}".`);
|
||||
} else {
|
||||
await api.deleteCategory(deleteConfirm.id);
|
||||
setMessage(`Deleted category "${deleteConfirm.title}".`);
|
||||
}
|
||||
onRefreshData();
|
||||
setTimeout(() => setMessage(null), 3500);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Delete operation failed');
|
||||
} finally {
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newCatTitle.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.createCategory(newCatTitle.trim());
|
||||
setNewCatTitle('');
|
||||
onRefreshData();
|
||||
setMessage('Category created.');
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create category');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="settings-container">
|
||||
{/* Settings Tab Navigation (Responsive sidebar on desktop / top chips on mobile) */}
|
||||
<div className="settings-nav">
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'feeds' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('feeds')}
|
||||
>
|
||||
<Rss size={15} />
|
||||
<span>Subscriptions ({feeds.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'categories' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('categories')}
|
||||
>
|
||||
<Folder size={15} />
|
||||
<span>Categories ({categories.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'opml' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('opml')}
|
||||
>
|
||||
<FileSpreadsheet size={15} />
|
||||
<span>OPML Migration</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-nav-item ${activeTab === 'account' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('account')}
|
||||
>
|
||||
<Shield size={15} />
|
||||
<span>Account & System</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Settings Content Area */}
|
||||
<div className="settings-content-area">
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
background: '#f0fdf4',
|
||||
border: '1px solid #bbf7d0',
|
||||
color: 'var(--success)',
|
||||
padding: '0.65rem 1rem',
|
||||
borderRadius: 6,
|
||||
marginBottom: '1.25rem',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Subscriptions Management Tab */}
|
||||
{activeTab === 'feeds' && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '1.25rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Subscription Management
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Configure background pulling interval, request timeouts, and categories per feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button className="btn-primary" onClick={onOpenAddFeed}>
|
||||
<Plus size={14} />
|
||||
<span>Add Feed</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-card" style={{ padding: '0.85rem' }}>
|
||||
<div style={{ position: 'relative', marginBottom: '0.85rem' }}>
|
||||
<Search
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0.75rem',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ paddingLeft: '2.2rem' }}
|
||||
placeholder="Filter by title, URL or category..."
|
||||
value={feedSearch}
|
||||
onChange={(e) => setFeedSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left', color: 'var(--text-muted)' }}>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>FEED</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>CATEGORY</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>SCHEDULE</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem' }}>STATUS</th>
|
||||
<th style={{ padding: '0.5rem 0.65rem', textAlign: 'right' }}>ACTIONS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFeeds.map((feed) => (
|
||||
<tr key={feed.id} style={{ borderBottom: '1px solid var(--border-light)' }}>
|
||||
<td style={{ padding: '0.65rem 0.65rem', maxWidth: 260 }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{feed.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{feed.feed_url}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
||||
{feed.category_title ? (
|
||||
<span className="tag-badge">{feed.category_title}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-dim)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: '0.72rem', color: 'var(--text-secondary)' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Clock size={11} /> {feed.refresh_interval_minutes || 30}m
|
||||
</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--text-muted)' }}>
|
||||
<Timer size={11} /> {feed.timeout_seconds || 10}s
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', whiteSpace: 'nowrap' }}>
|
||||
{feed.fetch_error ? (
|
||||
<button
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: 'var(--danger)',
|
||||
borderRadius: 4,
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.72rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
}}
|
||||
onClick={() => setDebugFeed(feed)}
|
||||
title="Click to view error details"
|
||||
>
|
||||
<AlertCircle size={11} /> Error Logs
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: 'var(--success)', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||
● Healthy
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.65rem 0.65rem', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 3 }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28 }}
|
||||
title="Edit schedule and settings"
|
||||
onClick={() => openEditModal(feed)}
|
||||
>
|
||||
<Edit2 size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28 }}
|
||||
title="Fetch Now"
|
||||
onClick={() => handleRefreshFeed(feed.id)}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 28, height: 28, color: 'var(--danger)' }}
|
||||
title="Unsubscribe"
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
type: 'feed',
|
||||
id: feed.id,
|
||||
title: feed.title,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Categories Tab */}
|
||||
{activeTab === 'categories' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Category Management
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Create, organize, and delete category folders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Category */}
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Add New Category</h3>
|
||||
<form onSubmit={handleCreateCategory} style={{ display: 'flex', gap: '0.6rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Category name (e.g. AI Research, Finance)..."
|
||||
value={newCatTitle}
|
||||
onChange={(e) => setNewCatTitle(e.target.value)}
|
||||
style={{ maxWidth: 360 }}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
<Plus size={14} />
|
||||
<span>Create Category</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Existing Categories */}
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Existing Categories ({categories.length})</h3>
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.65rem 0',
|
||||
borderBottom: '1px solid var(--border-light)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem' }}>
|
||||
<Folder size={15} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontWeight: 600, color: 'var(--text-main)' }}>{cat.title}</span>
|
||||
<span className="badge">{cat.feed_count || 0} feeds</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() =>
|
||||
setDeleteConfirm({
|
||||
type: 'category',
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
})
|
||||
}
|
||||
title="Delete Category"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. OPML Tab */}
|
||||
{activeTab === 'opml' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
OPML Migration & Backup
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Easily import from external RSS services or download a complete backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{opmlResult && (
|
||||
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: 'var(--success)', padding: '0.85rem 1rem', borderRadius: 6, marginBottom: '1.25rem', fontSize: '0.85rem' }}>
|
||||
{opmlResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Import Subscriptions</h3>
|
||||
<p className="settings-card-subtitle">
|
||||
Select an OPML XML file exported from Miniflux, Feedly, or NetNewsWire.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed var(--border-dark)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
background: 'var(--bg-app)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
onClick={() => document.getElementById('settings-opml-file')?.click()}
|
||||
>
|
||||
<input
|
||||
id="settings-opml-file"
|
||||
type="file"
|
||||
accept=".opml,.xml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setOpmlFile(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<UploadCloud size={30} style={{ color: 'var(--text-muted)', margin: '0 auto 0.5rem auto' }} />
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
|
||||
{opmlFile ? opmlFile.name : 'Click to select OPML file'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opmlFile && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleImportOPML}
|
||||
disabled={opmlLoading}
|
||||
>
|
||||
{opmlLoading ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" /> Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Import {opmlFile.name}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Export Subscriptions</h3>
|
||||
<p className="settings-card-subtitle">
|
||||
Download an OPML 2.0 XML file containing all your {feeds.length} subscribed feeds and folder trees.
|
||||
</p>
|
||||
|
||||
<button className="btn-secondary" onClick={handleExportOPML}>
|
||||
<Download size={14} />
|
||||
<span>Export OPML Backup</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. Account & System Tab */}
|
||||
{activeTab === 'account' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<h2 style={{ fontSize: '1.2rem', fontWeight: 700, color: 'var(--text-main)' }}>
|
||||
Account & System Health
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
Single-user gateway credentials and storage metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Admin Profile</h3>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Username:</strong> {currentUser.username}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>User ID:</strong> <code>{currentUser.id}</code>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Access Mode:</strong> Single-User RSS Information Gateway
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<h3 className="settings-card-title">Storage & Engine Stats</h3>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Database Engine:</strong> SQLite 3 with WAL (Write-Ahead Logging) Mode
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Total Subscribed Feeds:</strong> {stats?.total_feeds || feeds.length}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.4rem' }}>
|
||||
<strong>Total Unread Articles:</strong> {stats?.total_unread || 0}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Total Starred Articles:</strong> {stats?.total_starred || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Feed Modal (Pulling interval, timeout, category, title) */}
|
||||
{editingFeed && (
|
||||
<div className="modal-overlay" onClick={() => setEditingFeed(null)}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Edit Subscription Settings</h3>
|
||||
<button className="btn-icon" onClick={() => setEditingFeed(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveFeed}>
|
||||
<div className="modal-body">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Feed Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Feed URL</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={editingFeed.feed_url}
|
||||
disabled
|
||||
style={{ background: 'var(--bg-app)', color: 'var(--text-muted)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Category</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editCategoryId}
|
||||
onChange={(e) => setEditCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">(Uncategorized)</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Pull Interval</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editInterval}
|
||||
onChange={(e) => setEditInterval(Number(e.target.value))}
|
||||
>
|
||||
<option value={5}>Every 5 minutes</option>
|
||||
<option value={15}>Every 15 minutes</option>
|
||||
<option value={30}>Every 30 minutes</option>
|
||||
<option value={60}>Every 1 hour</option>
|
||||
<option value={120}>Every 2 hours</option>
|
||||
<option value={360}>Every 6 hours</option>
|
||||
<option value={1440}>Every 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Request Timeout</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editTimeout}
|
||||
onChange={(e) => setEditTimeout(Number(e.target.value))}
|
||||
>
|
||||
<option value={5}>5 seconds (Fast)</option>
|
||||
<option value={10}>10 seconds (Standard)</option>
|
||||
<option value={15}>15 seconds</option>
|
||||
<option value={30}>30 seconds (Slow)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => setEditingFeed(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={editSaving}>
|
||||
{editSaving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Logs Modal (Persistent error inspector) */}
|
||||
{debugFeed && (
|
||||
<div className="modal-overlay" onClick={() => setDebugFeed(null)}>
|
||||
<div className="modal-card" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<AlertCircle size={17} style={{ color: 'var(--danger)' }} />
|
||||
<h3>Feed Fetch Diagnostics</h3>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={() => setDebugFeed(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<strong>Feed:</strong> {debugFeed.title}
|
||||
</div>
|
||||
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: 'var(--text-muted)', wordBreak: 'break-all' }}>
|
||||
<strong>URL:</strong> {debugFeed.feed_url}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<label className="form-label">Error Log Output</label>
|
||||
<pre
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: 'var(--danger)',
|
||||
padding: '0.85rem',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.78rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{debugFeed.fetch_error || 'No error recorded.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
handleRefreshFeed(debugFeed.id);
|
||||
setDebugFeed(null);
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>Retry Fetch Now</span>
|
||||
</button>
|
||||
|
||||
<button className="btn-primary" onClick={() => setDebugFeed(null)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Solid Confirmation Modal for Deletion (Never auto-dismisses) */}
|
||||
{deleteConfirm && (
|
||||
<div className="modal-overlay" onClick={() => setDeleteConfirm(null)}>
|
||||
<div className="modal-card" style={{ maxWidth: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3 style={{ color: 'var(--danger)' }}>Confirm Deletion</h3>
|
||||
<button className="btn-icon" onClick={() => setDeleteConfirm(null)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ fontSize: '0.85rem', lineHeight: 1.5 }}>
|
||||
Are you sure you want to {deleteConfirm.type === 'feed' ? 'unsubscribe from' : 'delete category'}{' '}
|
||||
<strong>"{deleteConfirm.title}"</strong>?
|
||||
{deleteConfirm.type === 'category' && (
|
||||
<p style={{ marginTop: '0.5rem', color: 'var(--text-muted)', fontSize: '0.78rem' }}>
|
||||
Feeds inside this category will remain and become uncategorized.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0.85rem 1.25rem', borderTop: '1px solid var(--border)', display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button className="btn-secondary" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
style={{ background: 'var(--danger)', borderColor: 'var(--danger)' }}
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Category, Feed, FilterState, Stats } from '../types';
|
||||
import {
|
||||
Inbox,
|
||||
Star,
|
||||
Layers,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Rss,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SidebarProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
stats: Stats | null;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
stats,
|
||||
}) => {
|
||||
const [collapsedCats, setCollapsedCats] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] }));
|
||||
};
|
||||
|
||||
// Group feeds by category
|
||||
const categorizedFeeds: Record<string, Feed[]> = {};
|
||||
const uncategorizedFeeds: Feed[] = [];
|
||||
|
||||
feeds.forEach((f) => {
|
||||
if (f.category_id) {
|
||||
if (!categorizedFeeds[f.category_id]) {
|
||||
categorizedFeeds[f.category_id] = [];
|
||||
}
|
||||
categorizedFeeds[f.category_id].push(f);
|
||||
} else {
|
||||
uncategorizedFeeds.push(f);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sidebar-scroll-area">
|
||||
{/* Core Navigation Items */}
|
||||
<div
|
||||
className={`sidebar-nav-item ${
|
||||
filter.view === 'inbox' && !filter.feedId && !filter.categoryId
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'inbox',
|
||||
feedId: undefined,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Inbox size={16} />
|
||||
<span>Inbox</span>
|
||||
</div>
|
||||
{stats && stats.total_unread > 0 && (
|
||||
<span className="badge unread">{stats.total_unread}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sidebar-nav-item ${
|
||||
filter.view === 'starred' && !filter.feedId && !filter.categoryId
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'starred',
|
||||
feedId: undefined,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Star size={16} style={{ color: 'var(--star)' }} />
|
||||
<span>Starred</span>
|
||||
</div>
|
||||
{stats && stats.total_starred > 0 && (
|
||||
<span className="badge">{stats.total_starred}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`sidebar-nav-item ${
|
||||
filter.view === 'all' && !filter.feedId && !filter.categoryId
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'all',
|
||||
feedId: undefined,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Layers size={16} />
|
||||
<span>All Articles</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Section */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<div className="sidebar-section-header">
|
||||
<span>Categories</span>
|
||||
</div>
|
||||
|
||||
{categories.map((cat) => {
|
||||
const catFeeds = categorizedFeeds[cat.id] || [];
|
||||
const isCollapsed = collapsedCats[cat.id];
|
||||
const isCatActive = filter.categoryId === cat.id && !filter.feedId;
|
||||
const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0);
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<div
|
||||
className={`sidebar-nav-item ${isCatActive ? 'active' : ''}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
categoryId: cat.id,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<button
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: 0,
|
||||
marginRight: -3,
|
||||
}}
|
||||
onClick={(e) => toggleCategoryCollapse(cat.id, e)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
{isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />}
|
||||
<span>{cat.title}</span>
|
||||
</div>
|
||||
|
||||
{catUnread > 0 && <span className="badge unread">{catUnread}</span>}
|
||||
</div>
|
||||
|
||||
{/* Category feeds */}
|
||||
{!isCollapsed &&
|
||||
catFeeds.map((feed) => {
|
||||
const isFeedActive = filter.feedId === feed.id;
|
||||
return (
|
||||
<div
|
||||
key={feed.id}
|
||||
className={`feed-item ${isFeedActive ? 'active' : ''}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
feedId: feed.id,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Rss size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span className="feed-title-text" title={feed.title}>
|
||||
{feed.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge unread">{feed.unread_count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uncategorized Feeds */}
|
||||
{uncategorizedFeeds.length > 0 && (
|
||||
<div style={{ marginTop: '0.6rem' }}>
|
||||
<div className="sidebar-section-header">
|
||||
<span>Feeds</span>
|
||||
</div>
|
||||
|
||||
{uncategorizedFeeds.map((feed) => {
|
||||
const isFeedActive = filter.feedId === feed.id;
|
||||
return (
|
||||
<div
|
||||
key={feed.id}
|
||||
className={`sidebar-nav-item ${isFeedActive ? 'active' : ''}`}
|
||||
style={{ paddingLeft: '0.65rem' }}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
feedId: feed.id,
|
||||
categoryId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className="sidebar-nav-label">
|
||||
<Rss size={14} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span className="feed-title-text" title={feed.title}>
|
||||
{feed.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{feed.unread_count > 0 && (
|
||||
<span className="badge unread">{feed.unread_count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Category, Feed, FeedEntry, FilterState, Stats } from '../types';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Share2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
CheckCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ThreePaneViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
feeds: Feed[];
|
||||
stats: Stats | null;
|
||||
entries: FeedEntry[];
|
||||
selectedIndex: number;
|
||||
onSelectEntry: (index: number) => void;
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onMarkAllRead: () => void;
|
||||
sidebarCollapsed: boolean;
|
||||
}
|
||||
|
||||
export const ThreePaneView: React.FC<ThreePaneViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
feeds,
|
||||
stats,
|
||||
entries,
|
||||
selectedIndex,
|
||||
onSelectEntry,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
onMarkAllRead,
|
||||
sidebarCollapsed,
|
||||
}) => {
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const readerPaneRef = useRef<HTMLDivElement>(null);
|
||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedEntry = entries[selectedIndex] || entries[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0 && selectedIndex < entries.length) {
|
||||
const el = cardRefs.current[selectedIndex];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, entries.length]);
|
||||
|
||||
// Robust Auto-Read Detection (Dwell Time for short posts + IntersectionObserver for end of post + Scroll Progress)
|
||||
useEffect(() => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
|
||||
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Check if the content fits entirely in the viewport (short article)
|
||||
const checkViewportFit = () => {
|
||||
if (readerPaneRef.current) {
|
||||
const { scrollHeight, clientHeight } = readerPaneRef.current;
|
||||
// If article is short (fits or almost fits), mark as read after 1.5s of reading
|
||||
if (scrollHeight <= clientHeight + 60) {
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
// For longer articles, if user stays focused for 5 seconds, also auto-read
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to allow DOM render calculation
|
||||
const timeout = setTimeout(checkViewportFit, 100);
|
||||
|
||||
// IntersectionObserver to observe bottom of article
|
||||
let observer: IntersectionObserver | null = null;
|
||||
if (endSentinelRef.current && readerPaneRef.current) {
|
||||
observer = new IntersectionObserver(
|
||||
(entriesObs) => {
|
||||
entriesObs.forEach((entryObs) => {
|
||||
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: readerPaneRef.current,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
observer.observe(endSentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (dwellTimer) clearTimeout(dwellTimer);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
||||
|
||||
const handleReaderScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
||||
if (scrollProgress >= 0.85) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
};
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="three-pane-container">
|
||||
{/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */}
|
||||
<div className={`sidebar-pane ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
{!sidebarCollapsed && (
|
||||
<Sidebar
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
categories={categories}
|
||||
feeds={feeds}
|
||||
stats={stats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pane 2: Article List (Reeder 4 minimal style) */}
|
||||
<div className="article-list-pane">
|
||||
<div className="article-list-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
<span>{getListTitle()}</span>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{entries.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onMarkAllRead}
|
||||
title="Mark all as read (Shift + A)"
|
||||
style={{ width: 28, height: 28 }}
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="article-list-scroll-area">
|
||||
{entries.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>✨</div>
|
||||
<h3 style={{ fontSize: '0.9rem' }}>Inbox Zero</h3>
|
||||
<p style={{ fontSize: '0.75rem' }}>No articles to read here.</p>
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const snippet = stripHtml(entry.content || entry.raw_content, 100);
|
||||
const relativeTime = formatRelativeDate(entry.published_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
ref={(el) => (cardRefs.current[index] = el)}
|
||||
className={`article-item-card ${isSelected ? 'selected' : ''} ${
|
||||
entry.is_read ? 'is-read' : ''
|
||||
}`}
|
||||
onClick={() => onSelectEntry(index)}
|
||||
>
|
||||
<div className="article-item-top">
|
||||
<span className="article-item-source">{entry.feed_title}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{relativeTime}</span>
|
||||
{!entry.is_read && <div className="unread-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="article-item-title">{entry.title || 'Untitled'}</h4>
|
||||
|
||||
{snippet && <p className="article-item-snippet">{snippet}</p>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pane 3: Distraction-Free Reading Canvas */}
|
||||
<div className="reader-pane" ref={readerPaneRef} onScroll={handleReaderScroll}>
|
||||
{selectedEntry ? (
|
||||
<>
|
||||
{/* Top Toolbar */}
|
||||
<div className="reader-toolbar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleStar(selectedEntry)}
|
||||
title={selectedEntry.is_starred ? 'Unstar (S)' : 'Star (S)'}
|
||||
>
|
||||
<Star
|
||||
size={16}
|
||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleRead(selectedEntry)}
|
||||
title={selectedEntry.is_read ? 'Mark Unread (M)' : 'Mark Read (M)'}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<a
|
||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
||||
>
|
||||
<Share2 size={13} />
|
||||
<span>Share</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary"
|
||||
style={{ fontSize: '0.75rem', padding: '0.28rem 0.6rem' }}
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>Original Link (V)</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Content */}
|
||||
<div className="reader-content-canvas">
|
||||
<div className="reader-source-bar">
|
||||
<span className="tag-badge">
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{' '}
|
||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* End of article sentinel for auto-read observer */}
|
||||
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">📖</div>
|
||||
<h3>No Article Selected</h3>
|
||||
<p>Select an article from the list or press 'j' / 'k' to read.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,391 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Category, FeedEntry, FilterState } from '../types';
|
||||
import { extractFirstImage, formatRelativeDate, stripHtml, estimateReadingTime } from '../utils';
|
||||
import {
|
||||
Star,
|
||||
Inbox,
|
||||
Star as StarIcon,
|
||||
Layers,
|
||||
Rss,
|
||||
ArrowLeft,
|
||||
Share2,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface WaterfallViewProps {
|
||||
filter: FilterState;
|
||||
onFilterChange: (newFilter: Partial<FilterState>) => void;
|
||||
categories: Category[];
|
||||
entries: FeedEntry[];
|
||||
onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const WaterfallView: React.FC<WaterfallViewProps> = ({
|
||||
filter,
|
||||
onFilterChange,
|
||||
categories,
|
||||
entries,
|
||||
onToggleRead,
|
||||
onToggleStar,
|
||||
}) => {
|
||||
const [selectedEntry, setSelectedEntry] = useState<FeedEntry | null>(null);
|
||||
const readerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCardClick = (entry: FeedEntry) => {
|
||||
setSelectedEntry(entry);
|
||||
};
|
||||
|
||||
// Robust Auto-Read Detection in Waterfall Full-Screen Reader
|
||||
useEffect(() => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
|
||||
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const checkViewportFit = () => {
|
||||
if (readerScrollRef.current) {
|
||||
const { scrollHeight, clientHeight } = readerScrollRef.current;
|
||||
// Short article: auto mark as read after 1.5s
|
||||
if (scrollHeight <= clientHeight + 60) {
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
// Longer article: auto mark read after 5s active viewing
|
||||
dwellTimer = setTimeout(() => {
|
||||
if (selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(checkViewportFit, 100);
|
||||
|
||||
// IntersectionObserver on bottom sentinel
|
||||
let observer: IntersectionObserver | null = null;
|
||||
if (endSentinelRef.current && readerScrollRef.current) {
|
||||
observer = new IntersectionObserver(
|
||||
(entriesObs) => {
|
||||
entriesObs.forEach((entryObs) => {
|
||||
if (entryObs.isIntersecting && selectedEntry && !selectedEntry.is_read) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: readerScrollRef.current,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
observer.observe(endSentinelRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
if (dwellTimer) clearTimeout(dwellTimer);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, [selectedEntry?.id, selectedEntry?.is_read, onToggleRead]);
|
||||
|
||||
const handlePostScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!selectedEntry || selectedEntry.is_read) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const scrollProgress = (scrollTop + clientHeight) / scrollHeight;
|
||||
if (scrollProgress >= 0.85) {
|
||||
onToggleRead(selectedEntry);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="xhs-waterfall-container">
|
||||
{/* Top Category Tabs (Xiaohongshu App Style - No Left Sidebar) */}
|
||||
<div className="xhs-tabs-bar">
|
||||
<button
|
||||
className={`xhs-tab-item ${
|
||||
filter.view === 'inbox' && !filter.categoryId ? 'active' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'inbox',
|
||||
categoryId: undefined,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Inbox size={13} />
|
||||
<span>Inbox</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`xhs-tab-item ${
|
||||
filter.view === 'starred' && !filter.categoryId ? 'active' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'starred',
|
||||
categoryId: undefined,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<StarIcon size={13} />
|
||||
<span>Starred</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`xhs-tab-item ${
|
||||
filter.view === 'all' && !filter.categoryId ? 'active' : ''
|
||||
}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
view: 'all',
|
||||
categoryId: undefined,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Layers size={13} />
|
||||
<span>All</span>
|
||||
</button>
|
||||
|
||||
{/* Categories as Horizontal Chips */}
|
||||
{categories.map((cat) => {
|
||||
const isActive = filter.categoryId === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`xhs-tab-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() =>
|
||||
onFilterChange({
|
||||
categoryId: cat.id,
|
||||
feedId: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>{cat.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* True Xiaohongshu Waterfall (Masonry Grid with Staggered Heights) */}
|
||||
<div className="xhs-waterfall-scroll">
|
||||
{entries.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">🎉</div>
|
||||
<h3>Inbox Zero</h3>
|
||||
<p>No articles to display in this feed.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xhs-masonry">
|
||||
{entries.map((entry) => {
|
||||
const coverImg = extractFirstImage(entry.content || entry.raw_content);
|
||||
const snippet = stripHtml(entry.content || entry.raw_content, 90);
|
||||
const relativeDate = formatRelativeDate(entry.published_at);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="xhs-masonry-item">
|
||||
<div
|
||||
className={`xhs-card ${entry.is_read ? 'is-read' : ''}`}
|
||||
onClick={() => handleCardClick(entry)}
|
||||
>
|
||||
{/* Card Cover (Xiaohongshu style image / fallback) */}
|
||||
<div className="xhs-cover-wrap">
|
||||
{coverImg ? (
|
||||
<img
|
||||
src={coverImg}
|
||||
alt=""
|
||||
className="xhs-cover-img"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="xhs-cover-fallback">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<Rss size={20} />
|
||||
<span style={{ fontSize: '0.72rem', fontWeight: 600, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.feed_title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="xhs-card-body">
|
||||
<h3 className="xhs-card-title">{entry.title}</h3>
|
||||
{snippet && <p className="xhs-card-snippet">{snippet}</p>}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="xhs-card-footer">
|
||||
<div className="xhs-publisher-badge">
|
||||
<span>{entry.feed_title}</span>
|
||||
<span style={{ margin: '0 3px' }}>•</span>
|
||||
<span>{relativeDate}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{!entry.is_read && <div className="unread-dot" />}
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: entry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleStar(entry, e);
|
||||
}}
|
||||
title={entry.is_starred ? 'Unstar' : 'Star'}
|
||||
>
|
||||
<Star
|
||||
size={13}
|
||||
fill={entry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full-Screen Reading View with Back Arrow (Xiaohongshu post reader UX) */}
|
||||
{selectedEntry && (
|
||||
<div className="mobile-post-reader">
|
||||
{/* Top Bar with Back Arrow */}
|
||||
<div className="mobile-post-reader-header">
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{ width: 34, height: 34 }}
|
||||
onClick={() => setSelectedEntry(null)}
|
||||
title="Back to feed"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-secondary)',
|
||||
maxWidth: '40vw',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{selectedEntry.feed_title}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_starred ? 'var(--star)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleStar(selectedEntry)}
|
||||
title={selectedEntry.is_starred ? 'Unstar' : 'Star'}
|
||||
>
|
||||
<Star
|
||||
size={16}
|
||||
fill={selectedEntry.is_starred ? 'var(--star)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn-icon"
|
||||
style={{
|
||||
color: selectedEntry.is_read ? 'var(--success)' : undefined,
|
||||
}}
|
||||
onClick={() => onToggleRead(selectedEntry)}
|
||||
title={selectedEntry.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`${window.location.origin}/share/${selectedEntry.share_uuid || selectedEntry.id}.html`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-icon"
|
||||
title="Share Article"
|
||||
>
|
||||
<Share2 size={15} />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-icon"
|
||||
title="Open Original"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reading Body */}
|
||||
<div className="mobile-post-reader-content" ref={readerScrollRef} onScroll={handlePostScroll}>
|
||||
<div className="reader-content-canvas" style={{ padding: 0 }}>
|
||||
<div className="reader-source-bar">
|
||||
<span className="tag-badge">{selectedEntry.feed_title}</span>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Clock size={12} />{' '}
|
||||
{new Date(selectedEntry.published_at).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)}</span>
|
||||
{selectedEntry.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<UserIcon size={12} /> {selectedEntry.author}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="reader-headline">
|
||||
<a href={selectedEntry.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedEntry.title}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="reader-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: selectedEntry.content || selectedEntry.raw_content || '',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* End of article sentinel for auto-read observer */}
|
||||
<div ref={endSentinelRef} style={{ height: 1, marginTop: '2rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1132
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './index.css'
|
||||
|
||||
// Register Service Worker for PWA
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||
console.warn('Service Worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Category, Feed, FeedEntry, Stats, User } from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
if (res.status === 401 && path !== '/auth/login') {
|
||||
localStorage.removeItem('rsspipe_jwt');
|
||||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||||
throw new Error('Session expired or unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let errMsg = `Request failed: ${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const errJson = await res.json();
|
||||
if (errJson.error) errMsg = errJson.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Auth
|
||||
async login(username: string, password: string): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.token) {
|
||||
localStorage.setItem('rsspipe_jwt', res.token);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request('/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
localStorage.removeItem('rsspipe_jwt');
|
||||
}
|
||||
},
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return request<User>('/auth/me');
|
||||
},
|
||||
|
||||
// Stats
|
||||
async getStats(): Promise<Stats> {
|
||||
return request<Stats>('/stats');
|
||||
},
|
||||
|
||||
// Categories
|
||||
async listCategories(): Promise<Category[]> {
|
||||
return request<Category[]>('/categories');
|
||||
},
|
||||
|
||||
async createCategory(title: string): Promise<Category> {
|
||||
return request<Category>('/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
return request<void>(`/categories/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// Feeds
|
||||
async listFeeds(): Promise<Feed[]> {
|
||||
return request<Feed[]>('/feeds');
|
||||
},
|
||||
|
||||
async createFeed(feed_url: string, category_id?: string, title?: string): Promise<Feed> {
|
||||
return request<Feed>('/feeds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_url, category_id, title }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateFeed(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
category_id?: string;
|
||||
refresh_interval_minutes?: number;
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
): Promise<Feed> {
|
||||
return request<Feed>(`/feeds/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteFeed(id: string): Promise<void> {
|
||||
return request<void>(`/feeds/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async refreshFeed(id: string): Promise<Feed> {
|
||||
return request<Feed>(`/feeds/${id}/refresh`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async refreshAllFeeds(): Promise<void> {
|
||||
return request<void>('/feeds/refresh-all', { method: 'POST' });
|
||||
},
|
||||
|
||||
// Entries
|
||||
async listEntries(params: {
|
||||
view?: string;
|
||||
feed_id?: string;
|
||||
category_id?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ entries: FeedEntry[]; total: number; limit: number; offset: number }> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.view) query.set('view', params.view);
|
||||
if (params.feed_id) query.set('feed_id', params.feed_id);
|
||||
if (params.category_id) query.set('category_id', params.category_id);
|
||||
if (params.search) query.set('search', params.search);
|
||||
if (params.limit) query.set('limit', params.limit.toString());
|
||||
if (params.offset) query.set('offset', params.offset.toString());
|
||||
|
||||
return request(`/entries?${query.toString()}`);
|
||||
},
|
||||
|
||||
async updateRead(id: string, is_read: boolean): Promise<void> {
|
||||
return request(`/entries/${id}/read`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_read }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateStar(id: string, is_starred: boolean): Promise<void> {
|
||||
return request(`/entries/${id}/star`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_starred }),
|
||||
});
|
||||
},
|
||||
|
||||
async markAllRead(feed_id?: string, category_id?: string): Promise<void> {
|
||||
return request('/entries/mark-all-read', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ feed_id, category_id }),
|
||||
});
|
||||
},
|
||||
|
||||
// OPML
|
||||
async importOPML(file: File): Promise<{ imported_count: number; total_parsed: number }> {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}/opml/import`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to import OPML');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
},
|
||||
|
||||
exportOPMLUrl(): string {
|
||||
const token = localStorage.getItem('rsspipe_jwt');
|
||||
return `${API_BASE}/opml/export?token=${token || ''}`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
title: string;
|
||||
parent_id?: string;
|
||||
sort_order: number;
|
||||
feed_count?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string;
|
||||
title: string;
|
||||
feed_url: string;
|
||||
site_url?: string;
|
||||
category_id?: string;
|
||||
category_title?: string;
|
||||
is_virtual_digest: boolean;
|
||||
refresh_interval_minutes: number;
|
||||
timeout_seconds: number;
|
||||
etag?: string;
|
||||
last_modified?: string;
|
||||
last_fetched_at?: string;
|
||||
fetch_error?: string;
|
||||
unread_count: number;
|
||||
total_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface FeedEntry {
|
||||
id: string;
|
||||
feed_id: string;
|
||||
feed_title: string;
|
||||
feed_site_url?: string;
|
||||
guid: string;
|
||||
url: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
content: string;
|
||||
raw_content?: string;
|
||||
published_at: string;
|
||||
is_read: boolean;
|
||||
is_starred: boolean;
|
||||
share_uuid?: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
total_feeds: number;
|
||||
total_unread: number;
|
||||
total_starred: number;
|
||||
total_categories: number;
|
||||
}
|
||||
|
||||
export type ViewType = 'inbox' | 'starred' | 'all';
|
||||
export type ViewMode = 'split' | 'waterfall' | 'settings';
|
||||
|
||||
export interface FilterState {
|
||||
view: ViewType;
|
||||
categoryId?: string;
|
||||
feedId?: string;
|
||||
search: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utility functions for RSSPipe UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts the first image URL from HTML content for thumbnail previews (Xiaohongshu style)
|
||||
*/
|
||||
export function extractFirstImage(html?: string): string | null {
|
||||
if (!html) return null;
|
||||
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
if (match && match[1]) {
|
||||
const src = match[1];
|
||||
// Ignore tracking pixels, 1x1 gifs or tiny icons
|
||||
if (src.includes('feedads') || src.includes('stat?') || src.includes('pixel') || src.endsWith('.svg')) {
|
||||
return null;
|
||||
}
|
||||
return src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML tags and clean up text for previews
|
||||
*/
|
||||
export function stripHtml(html?: string, maxLength = 140): string {
|
||||
if (!html) return '';
|
||||
const text = html.replace(/<[^>]*>?/gm, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative date (e.g. 5m ago, 2h ago, May 14)
|
||||
*/
|
||||
export function formatRelativeDate(dateStr: string): string {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate reading time in minutes
|
||||
*/
|
||||
export function estimateReadingTime(content?: string): string {
|
||||
if (!content) return '1 min';
|
||||
const text = stripHtml(content, 99999);
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
const mins = Math.ceil(words / 200);
|
||||
return `${mins} min read`;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/share': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user