From 3742dbdce4888191ea6cbc304231d8716533c5bf Mon Sep 17 00:00:00 2001 From: haopengzhan Date: Mon, 24 Aug 2026 08:08:31 -0700 Subject: [PATCH] feat: scaffold initial RSS reader project with Go backend and React frontend --- .agent.md | 0 .gitignore | 31 + cmd/server/main.go | 81 + docs/001_initial_prd.md | 103 + docs/002_prd_overview.md | 513 +++++ go.mod | 24 + go.sum | 36 + internal/api/handlers.go | 530 +++++ internal/api/server.go | 91 + internal/auth/auth.go | 140 ++ internal/config/config.go | 67 + internal/crawler/crawler.go | 192 ++ internal/db/db.go | 679 +++++++ internal/db/db_test.go | 101 + internal/models/models.go | 118 ++ internal/opml/opml.go | 157 ++ internal/opml/opml_test.go | 66 + internal/parser/parser.go | 129 ++ web/index.html | 20 + web/package-lock.json | 1802 +++++++++++++++++ web/package.json | 23 + web/public/logo.svg | 12 + web/public/manifest.json | 16 + web/public/sw.js | 19 + web/src/App.tsx | 518 +++++ web/src/components/AddFeedModal.tsx | 148 ++ web/src/components/ArticleModal.tsx | 152 ++ web/src/components/CompactListView.tsx | 216 ++ web/src/components/Header.tsx | 274 +++ web/src/components/KeyboardShortcutsModal.tsx | 93 + web/src/components/LoginView.tsx | 96 + web/src/components/OPMLModal.tsx | 170 ++ web/src/components/SettingsView.tsx | 801 ++++++++ web/src/components/Sidebar.tsx | 246 +++ web/src/components/ThreePaneView.tsx | 327 +++ web/src/components/WaterfallView.tsx | 391 ++++ web/src/index.css | 1132 +++++++++++ web/src/main.tsx | 19 + web/src/services/api.ts | 195 ++ web/src/types.ts | 69 + web/src/utils.ts | 63 + web/src/vite-env.d.ts | 1 + web/tsconfig.json | 20 + web/vite.config.ts | 20 + 44 files changed, 9901 insertions(+) create mode 100644 .agent.md create mode 100644 .gitignore create mode 100644 cmd/server/main.go create mode 100644 docs/001_initial_prd.md create mode 100644 docs/002_prd_overview.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/handlers.go create mode 100644 internal/api/server.go create mode 100644 internal/auth/auth.go create mode 100644 internal/config/config.go create mode 100644 internal/crawler/crawler.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/models/models.go create mode 100644 internal/opml/opml.go create mode 100644 internal/opml/opml_test.go create mode 100644 internal/parser/parser.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/logo.svg create mode 100644 web/public/manifest.json create mode 100644 web/public/sw.js create mode 100644 web/src/App.tsx create mode 100644 web/src/components/AddFeedModal.tsx create mode 100644 web/src/components/ArticleModal.tsx create mode 100644 web/src/components/CompactListView.tsx create mode 100644 web/src/components/Header.tsx create mode 100644 web/src/components/KeyboardShortcutsModal.tsx create mode 100644 web/src/components/LoginView.tsx create mode 100644 web/src/components/OPMLModal.tsx create mode 100644 web/src/components/SettingsView.tsx create mode 100644 web/src/components/Sidebar.tsx create mode 100644 web/src/components/ThreePaneView.tsx create mode 100644 web/src/components/WaterfallView.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/services/api.ts create mode 100644 web/src/types.ts create mode 100644 web/src/utils.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.agent.md b/.agent.md new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a12c8b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..61ae09e --- /dev/null +++ b/cmd/server/main.go @@ -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.") +} diff --git a/docs/001_initial_prd.md b/docs/001_initial_prd.md new file mode 100644 index 0000000..91b4c3d --- /dev/null +++ b/docs/001_initial_prd.md @@ -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; + feedMetadata: Readonly; + triggerReason: string; +} + +// 输出契约 (Plugin 返回给主系统的指令) +interface PluginOutput { + action: "merge_metadata" | "drop_feed" | "none"; + metadataPatch?: { + $set?: Record; // 覆写字段,如 { aiSummary: "..." } + $push?: Record; // 追加数组元素,如 { 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,计算用户的实际停留时间与可见内容量,生成打分日志。 + * 反馈推流机制:周期性地将本地存储的遥测日志通过网络发送给后端模型训练/微调服务。 diff --git a/docs/002_prd_overview.md b/docs/002_prd_overview.md new file mode 100644 index 0000000..089f053 --- /dev/null +++ b/docs/002_prd_overview.md @@ -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/.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/) │ │ +│ └────────────────────┘ └─────────┬──────────┘ └─────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 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/.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/.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/.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/.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/.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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..42a5ca8 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d320bbd --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..948cf44 --- /dev/null +++ b/internal/api/handlers.go @@ -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/.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(` + + + + + %s - RSSPipe + + + +
+
+ %s + • + %s + %s +
+

%s ↗

+
+ %s +
+ +
+ +`, + e.Title, + e.FeedTitle, + e.PublishedAt.Format("Jan 02, 2006 15:04"), + func() string { + if e.Author != "" { + return "•By " + e.Author + "" + } + 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 +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..aab5936 --- /dev/null +++ b/internal/api/server.go @@ -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/.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 +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..bc97004 --- /dev/null +++ b/internal/auth/auth.go @@ -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)) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0f6324a --- /dev/null +++ b/internal/config/config.go @@ -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, + } +} diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go new file mode 100644 index 0000000..d813df1 --- /dev/null +++ b/internal/crawler/crawler.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..cb7d206 --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..118900b --- /dev/null +++ b/internal/db/db_test.go @@ -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: "

Content 1

", + PublishedAt: time.Now().Add(-1 * time.Hour), + }, + { + GUID: "item-2", + URL: "https://example.com/2", + Title: "Post Two", + Author: "Bob", + Content: "

Content 2

", + 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) + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..b18fdd3 --- /dev/null +++ b/internal/models/models.go @@ -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"` +} diff --git a/internal/opml/opml.go b/internal/opml/opml.go new file mode 100644 index 0000000..d48aded --- /dev/null +++ b/internal/opml/opml.go @@ -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 +} diff --git a/internal/opml/opml_test.go b/internal/opml/opml_test.go new file mode 100644 index 0000000..d1dad2b --- /dev/null +++ b/internal/opml/opml_test.go @@ -0,0 +1,66 @@ +package opml + +import ( + "testing" + + "github.com/haopengzhan/rsspipe/internal/models" +) + +func TestParseAndGenerateOPML(t *testing.T) { + sampleOPML := ` + + + Miniflux Subscriptions + + + + + + + + +` + + 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)) + } +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..0ca1b5c --- /dev/null +++ b/internal/parser/parser.go @@ -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 +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f5b190b --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + RSSPipe | Information Gateway + + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a67dae1 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1802 @@ +{ + "name": "rsspipe-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rsspipe-web", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.412", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.412.tgz", + "integrity": "sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.33.0.tgz", + "integrity": "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..728acb9 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/logo.svg b/web/public/logo.svg new file mode 100644 index 0000000..c002caa --- /dev/null +++ b/web/public/logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..a7cc5bf --- /dev/null +++ b/web/public/manifest.json @@ -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" + } + ] +} diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..362d951 --- /dev/null +++ b/web/public/sw.js @@ -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); + }) + ); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..283c771 --- /dev/null +++ b/web/src/App.tsx @@ -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(null); + const [authLoading, setAuthLoading] = useState(true); + + // App State with localStorage Persistence + const [filter, setFilter] = useState(() => { + const saved = localStorage.getItem('rsspipe_filter'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + // ignore + } + } + return { view: 'inbox', search: '' }; + }); + + const [viewMode, setViewMode] = useState(() => { + const saved = localStorage.getItem('rsspipe_view_mode') as ViewMode | null; + if (saved && (saved === 'split' || saved === 'waterfall' || saved === 'settings')) { + return saved; + } + return window.innerWidth < 768 ? 'waterfall' : 'split'; + }); + + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + return localStorage.getItem('rsspipe_sidebar_collapsed') === 'true'; + }); + + const [selectedEntryId, setSelectedEntryId] = useState(() => { + return localStorage.getItem('rsspipe_selected_entry_id'); + }); + + const [categories, setCategories] = useState([]); + const [feeds, setFeeds] = useState([]); + const [entries, setEntries] = useState([]); + const [stats, setStats] = useState(null); + const [focusedIndex, setFocusedIndex] = useState(0); + + const [isRefreshing, setIsRefreshing] = useState(false); + const [showAddFeedModal, setShowAddFeedModal] = useState(false); + const [showOPMLModal, setShowOPMLModal] = useState(false); + const [showShortcutsModal, setShowShortcutsModal] = useState(false); + + // Persist state updates to localStorage + 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 ( +
+
+ Loading RSSPipe... +
+
+ ); + } + + if (!currentUser) { + return setCurrentUser(user)} />; + } + + return ( +
+ {/* App Header */} +
setFilter((prev) => ({ ...prev, search: val }))} + viewMode={viewMode} + onViewModeChange={setViewMode} + isRefreshing={isRefreshing} + onRefreshAll={handleRefreshAll} + onOpenAddFeed={() => setShowAddFeedModal(true)} + onOpenShortcuts={() => setShowShortcutsModal(true)} + onLogout={async () => { + await api.logout(); + setCurrentUser(null); + }} + sidebarCollapsed={sidebarCollapsed} + onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)} + /> + + {/* Main View Mode Content */} + {viewMode === 'split' ? ( + 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' ? ( + setFilter((prev) => ({ ...prev, ...newF }))} + categories={categories} + entries={entries} + onToggleRead={handleToggleRead} + onToggleStar={handleToggleStar} + /> + ) : ( + { + loadSidebarData(); + loadEntries(); + }} + onOpenAddFeed={() => setShowAddFeedModal(true)} + /> + )} + + {/* Modals */} + {showAddFeedModal && ( + setShowAddFeedModal(false)} + onFeedAdded={() => { + loadSidebarData(); + loadEntries(); + }} + /> + )} + + {showOPMLModal && ( + setShowOPMLModal(false)} + onImportComplete={() => { + loadSidebarData(); + loadEntries(); + }} + /> + )} + + {showShortcutsModal && ( + setShowShortcutsModal(false)} /> + )} +
+ ); +}; diff --git a/web/src/components/AddFeedModal.tsx b/web/src/components/AddFeedModal.tsx new file mode 100644 index 0000000..5f7270d --- /dev/null +++ b/web/src/components/AddFeedModal.tsx @@ -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 = ({ + categories, + onClose, + onFeedAdded, +}) => { + const [feedUrl, setFeedUrl] = useState(''); + const [title, setTitle] = useState(''); + const [categoryId, setCategoryId] = useState(''); + const [newCatTitle, setNewCatTitle] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!feedUrl.trim()) return; + + setLoading(true); + setError(null); + + try { + let finalCatId: string | undefined = categoryId || undefined; + + // If user typed a new category name + if (newCatTitle.trim()) { + const newCat = await api.createCategory(newCatTitle.trim()); + finalCatId = newCat.id; + } + + const newFeed = await api.createFeed( + feedUrl.trim(), + finalCatId, + title.trim() || undefined + ); + + onFeedAdded(newFeed); + onClose(); + } catch (err: any) { + setError(err.message || 'Failed to add feed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Add New Subscription

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

+ + {entry.title} + +

+ +
+
+
+
+
+ ); +}; diff --git a/web/src/components/CompactListView.tsx b/web/src/components/CompactListView.tsx new file mode 100644 index 0000000..4ae887a --- /dev/null +++ b/web/src/components/CompactListView.tsx @@ -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 = ({ + entries, + selectedIndex, + onSelectEntry, + onToggleRead, + onToggleStar, +}) => { + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + if (selectedIndex >= 0 && selectedIndex < entries.length) { + const el = rowRefs.current[selectedIndex]; + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + }, [selectedIndex, entries.length]); + + if (entries.length === 0) { + return ( +
+
🎉
+

Inbox Zero!

+

No articles to display in this view.

+
+ ); + } + + const selectedEntry = entries[selectedIndex] || entries[0]; + + return ( +
+ {/* Left List Pane (Gmail style) */} +
+ {entries.map((entry, index) => { + const isSelected = index === selectedIndex; + const pubDate = new Date(entry.published_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); + + // Strip HTML tags for clean 1-line snippet + const snippet = (entry.content || entry.raw_content || '') + .replace(/<[^>]*>?/gm, '') + .substring(0, 120); + + return ( +
(rowRefs.current[index] = el)} + className={`compact-row ${isSelected ? 'focused' : ''} ${ + entry.is_read ? 'is-read' : '' + }`} + onClick={() => onSelectEntry(index)} + > + {/* Star toggle */} + + + {/* Mark read toggle */} + + + {/* Feed Title */} +
+ {entry.feed_title} +
+ + {/* Article Title & Snippet */} +
+ {entry.title} + - {snippet} +
+ + {/* Date */} +
{pubDate}
+
+ ); + })} +
+ + {/* Right Reading Pane */} + {selectedEntry && ( +
+
+
+ {selectedEntry.feed_title} + • + + {' '} + {new Date(selectedEntry.published_at).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} + + {selectedEntry.author && ( + <> + • + + {selectedEntry.author} + + + )} +
+ +

+ + {selectedEntry.title} ↗ + +

+ + {/* Actions Toolbar */} +
+ + + + + + + Open Original (V) + + + + + Public Share + +
+
+ +
+
+ )} +
+ ); +}; diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..ebfeb46 --- /dev/null +++ b/web/src/components/Header.tsx @@ -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 = ({ + search, + onSearchChange, + viewMode, + onViewModeChange, + isRefreshing, + onRefreshAll, + onOpenAddFeed, + onOpenShortcuts, + onLogout, + sidebarCollapsed, + onToggleSidebar, +}) => { + const searchInputRef = useRef(null); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.key === '/' && + document.activeElement?.tagName !== 'INPUT' && + document.activeElement?.tagName !== 'TEXTAREA' + ) { + e.preventDefault(); + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + return ( +
+ {/* Left: Logo & Sidebar Toggle */} +
+ {!isMobile && viewMode === 'split' && ( + + )} + +
onViewModeChange(isMobile ? 'waterfall' : 'split')} + > +
+ +
+ RSSPipe + {!isMobile && Gateway} +
+
+ + {/* Center: Search Bar */} +
+ + onSearchChange(e.target.value)} + placeholder={isMobile ? 'Search...' : "Search feeds, 'is:unread'..."} + /> + {!search && !isMobile && /} +
+ + {/* Right: Actions */} + {isMobile ? ( + // Mobile Header Right: 3 Dots Menu Button +
+ + + {mobileMenuOpen && ( + <> +
setMobileMenuOpen(false)} + /> +
+ + + + +
+ + + + + + + +
+ + +
+ + )} +
+ ) : ( + // Desktop Header Right Actions +
+ + + + +
+ + + +
+ + + + +
+ )} +
+ ); +}; diff --git a/web/src/components/KeyboardShortcutsModal.tsx b/web/src/components/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000..2312876 --- /dev/null +++ b/web/src/components/KeyboardShortcutsModal.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { X, Keyboard } from 'lucide-react'; + +interface KeyboardShortcutsModalProps { + onClose: () => void; +} + +export const KeyboardShortcutsModal: React.FC = ({ + onClose, +}) => { + const shortcuts = [ + { key: 'j / k', desc: 'Navigate to Next / Previous article' }, + { key: 'o / Enter', desc: 'Open / Expand selected article' }, + { key: 'v', desc: 'Open original link in a new browser tab' }, + { key: 's', desc: 'Star / Unstar article' }, + { key: 'm', desc: 'Toggle Read / Unread status' }, + { key: 'e / Shift+I', desc: 'Archive / Mark Read and advance to next' }, + { key: 'Shift + A', desc: 'Mark all items in current view as read' }, + { key: '/', desc: 'Focus search bar' }, + { key: 'r', desc: 'Refresh all feeds' }, + { key: '1', desc: 'Switch to Reeder 4 Minimal 3-Pane Split View' }, + { key: '2', desc: 'Switch to Xiaohongshu Waterfall / Masonry View' }, + { key: '3', desc: 'Switch to Continuous Stream View' }, + { key: 'a', desc: 'Add new subscription modal' }, + { key: '?', desc: 'Open keyboard shortcuts cheatsheet' }, + ]; + + return ( +
+
e.stopPropagation()} style={{ maxWidth: 580 }}> +
+
+ +

Google Reader & Gmail Keyboard Shortcuts

+
+ +
+ +
+ + + + + + + + + {shortcuts.map((sc, i) => ( + + + + + ))} + +
+ SHORTCUT + + ACTION +
+ + {sc.key} + + + {sc.desc} +
+ +
+ Press ESC to close any modal. +
+
+
+
+ ); +}; diff --git a/web/src/components/LoginView.tsx b/web/src/components/LoginView.tsx new file mode 100644 index 0000000..df92daf --- /dev/null +++ b/web/src/components/LoginView.tsx @@ -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 = ({ onLoginSuccess }) => { + const [username, setUsername] = useState('admin'); + const [password, setPassword] = useState('admin123456'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + const res = await api.login(username, password); + onLoginSuccess(res.user); + } catch (err: any) { + setError(err.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+ RSSPipe +
+

RSS-Driven Information Gateway

+ + {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + placeholder="admin" + required + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + /> +
+ + + +
+ Default single-user credentials: admin / admin123456 +
+
+
+
+ ); +}; diff --git a/web/src/components/OPMLModal.tsx b/web/src/components/OPMLModal.tsx new file mode 100644 index 0000000..df09cbb --- /dev/null +++ b/web/src/components/OPMLModal.tsx @@ -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 = ({ + onClose, + onImportComplete, +}) => { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState<{ imported: number; total: number } | null>(null); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+
e.stopPropagation()}> +
+

OPML Migration & Backup

+ +
+ +
+ {error &&
{error}
} + + {result && ( +
+ + + Successfully imported {result.imported} new feeds (out of {result.total} parsed in OPML). Background fetch initiated! + +
+ )} + + {/* Import Section */} +
+

+ Import Subscriptions (from Miniflux / Google Reader / OPML) +

+

+ Upload an OPML 2.0 XML file to seamlessly import all your feeds and categories. +

+ +
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', + }} + > + + +
+ {file ? file.name : 'Click to browse or drag & drop OPML file'} +
+
+ Supports .opml or .xml +
+
+ + {file && ( +
+ +
+ )} +
+ + {/* Export Section */} +
+

+ Export Subscriptions +

+

+ Download a complete OPML backup of all your subscribed feeds and folders. +

+ +
+
+
+
+ ); +}; diff --git a/web/src/components/SettingsView.tsx b/web/src/components/SettingsView.tsx new file mode 100644 index 0000000..d7744f1 --- /dev/null +++ b/web/src/components/SettingsView.tsx @@ -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 = ({ + 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(null); + + // Edit Feed Modal State + const [editingFeed, setEditingFeed] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const [editCategoryId, setEditCategoryId] = useState(''); + const [editInterval, setEditInterval] = useState(30); + const [editTimeout, setEditTimeout] = useState(10); + const [editSaving, setEditSaving] = useState(false); + + // Debug Log Modal State + const [debugFeed, setDebugFeed] = useState(null); + + // Custom Delete Confirmation Modal State + const [deleteConfirm, setDeleteConfirm] = useState<{ + type: 'feed' | 'category'; + id: string; + title: string; + } | null>(null); + + // OPML state + const [opmlFile, setOpmlFile] = useState(null); + const [opmlLoading, setOpmlLoading] = useState(false); + const [opmlResult, setOpmlResult] = useState(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 ( +
+ {/* Settings Tab Navigation (Responsive sidebar on desktop / top chips on mobile) */} +
+ + + + + + + +
+ + {/* Main Settings Content Area */} +
+ {message && ( +
+ + {message} +
+ )} + + {/* 1. Subscriptions Management Tab */} + {activeTab === 'feeds' && ( +
+
+
+

+ Subscription Management +

+

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

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

+ Category Management +

+

+ Create, organize, and delete category folders. +

+
+ + {/* Create Category */} +
+

Add New Category

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

Existing Categories ({categories.length})

+
+ {categories.map((cat) => ( +
+
+ + {cat.title} + {cat.feed_count || 0} feeds +
+ + +
+ ))} +
+
+
+ )} + + {/* 3. OPML Tab */} + {activeTab === 'opml' && ( +
+
+

+ OPML Migration & Backup +

+

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

+
+ + {opmlResult && ( +
+ {opmlResult} +
+ )} + +
+

Import Subscriptions

+

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

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

Export Subscriptions

+

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

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

+ Account & System Health +

+

+ Single-user gateway credentials and storage metrics. +

+
+ +
+

Admin Profile

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

Storage & Engine Stats

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

Edit Subscription Settings

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

Feed Fetch Diagnostics

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

Confirm Deletion

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

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

+ )} +
+ +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..eba92e5 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -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) => void; + categories: Category[]; + feeds: Feed[]; + stats: Stats | null; +} + +export const Sidebar: React.FC = ({ + filter, + onFilterChange, + categories, + feeds, + stats, +}) => { + const [collapsedCats, setCollapsedCats] = useState>({}); + + const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] })); + }; + + // Group feeds by category + const categorizedFeeds: Record = {}; + const uncategorizedFeeds: Feed[] = []; + + feeds.forEach((f) => { + if (f.category_id) { + if (!categorizedFeeds[f.category_id]) { + categorizedFeeds[f.category_id] = []; + } + categorizedFeeds[f.category_id].push(f); + } else { + uncategorizedFeeds.push(f); + } + }); + + return ( +
+ {/* Core Navigation Items */} +
+ onFilterChange({ + view: 'inbox', + feedId: undefined, + categoryId: undefined, + }) + } + > +
+ + Inbox +
+ {stats && stats.total_unread > 0 && ( + {stats.total_unread} + )} +
+ +
+ onFilterChange({ + view: 'starred', + feedId: undefined, + categoryId: undefined, + }) + } + > +
+ + Starred +
+ {stats && stats.total_starred > 0 && ( + {stats.total_starred} + )} +
+ +
+ onFilterChange({ + view: 'all', + feedId: undefined, + categoryId: undefined, + }) + } + > +
+ + All Articles +
+
+ + {/* Categories Section */} + {categories.length > 0 && ( +
+
+ Categories +
+ + {categories.map((cat) => { + const catFeeds = categorizedFeeds[cat.id] || []; + const isCollapsed = collapsedCats[cat.id]; + const isCatActive = filter.categoryId === cat.id && !filter.feedId; + const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0); + + return ( +
+
+ onFilterChange({ + categoryId: cat.id, + feedId: undefined, + }) + } + > +
+ + {isCollapsed ? : } + {cat.title} +
+ + {catUnread > 0 && {catUnread}} +
+ + {/* Category feeds */} + {!isCollapsed && + catFeeds.map((feed) => { + const isFeedActive = filter.feedId === feed.id; + return ( +
+ onFilterChange({ + feedId: feed.id, + categoryId: undefined, + }) + } + > +
+ + + {feed.title} + +
+ + {feed.unread_count > 0 && ( + {feed.unread_count} + )} +
+ ); + })} +
+ ); + })} +
+ )} + + {/* Uncategorized Feeds */} + {uncategorizedFeeds.length > 0 && ( +
+
+ Feeds +
+ + {uncategorizedFeeds.map((feed) => { + const isFeedActive = filter.feedId === feed.id; + return ( +
+ onFilterChange({ + feedId: feed.id, + categoryId: undefined, + }) + } + > +
+ + + {feed.title} + +
+ + {feed.unread_count > 0 && ( + {feed.unread_count} + )} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/web/src/components/ThreePaneView.tsx b/web/src/components/ThreePaneView.tsx new file mode 100644 index 0000000..ecbc216 --- /dev/null +++ b/web/src/components/ThreePaneView.tsx @@ -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) => 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 = ({ + filter, + onFilterChange, + categories, + feeds, + stats, + entries, + selectedIndex, + onSelectEntry, + onToggleRead, + onToggleStar, + onMarkAllRead, + sidebarCollapsed, +}) => { + const cardRefs = useRef<(HTMLDivElement | null)[]>([]); + const readerPaneRef = useRef(null); + const endSentinelRef = useRef(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 | 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) => { + 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 ( +
+ {/* Pane 1: Sidebar (Collapsible & Independently Scrollable) */} +
+ {!sidebarCollapsed && ( + + )} +
+ + {/* Pane 2: Article List (Reeder 4 minimal style) */} +
+
+
+ {getListTitle()} + + {entries.length} + +
+ + +
+ +
+ {entries.length === 0 ? ( +
+
✨
+

Inbox Zero

+

No articles to read here.

+
+ ) : ( + entries.map((entry, index) => { + const isSelected = index === selectedIndex; + const snippet = stripHtml(entry.content || entry.raw_content, 100); + const relativeTime = formatRelativeDate(entry.published_at); + + return ( +
(cardRefs.current[index] = el)} + className={`article-item-card ${isSelected ? 'selected' : ''} ${ + entry.is_read ? 'is-read' : '' + }`} + onClick={() => onSelectEntry(index)} + > +
+ {entry.feed_title} +
+ {relativeTime} + {!entry.is_read &&
} +
+
+ +

{entry.title || 'Untitled'}

+ + {snippet &&

{snippet}

} +
+ ); + }) + )} +
+
+ + {/* Pane 3: Distraction-Free Reading Canvas */} +
+ {selectedEntry ? ( + <> + {/* Top Toolbar */} +
+
+ + + +
+ + +
+ + {/* Reading Content */} +
+
+ + {selectedEntry.feed_title} + + • + + {' '} + {new Date(selectedEntry.published_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + • + {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)} + {selectedEntry.author && ( + <> + • + + {selectedEntry.author} + + + )} +
+ +

+ + {selectedEntry.title} + +

+ +
+ + {/* End of article sentinel for auto-read observer */} +
+
+ + ) : ( +
+
📖
+

No Article Selected

+

Select an article from the list or press 'j' / 'k' to read.

+
+ )} +
+
+ ); +}; diff --git a/web/src/components/WaterfallView.tsx b/web/src/components/WaterfallView.tsx new file mode 100644 index 0000000..76dc8ae --- /dev/null +++ b/web/src/components/WaterfallView.tsx @@ -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) => void; + categories: Category[]; + entries: FeedEntry[]; + onToggleRead: (entry: FeedEntry, e?: React.MouseEvent) => void; + onToggleStar: (entry: FeedEntry, e?: React.MouseEvent) => void; +} + +export const WaterfallView: React.FC = ({ + filter, + onFilterChange, + categories, + entries, + onToggleRead, + onToggleStar, +}) => { + const [selectedEntry, setSelectedEntry] = useState(null); + const readerScrollRef = useRef(null); + const endSentinelRef = useRef(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 | 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) => { + if (!selectedEntry || selectedEntry.is_read) return; + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + const scrollProgress = (scrollTop + clientHeight) / scrollHeight; + if (scrollProgress >= 0.85) { + onToggleRead(selectedEntry); + } + }; + + return ( +
+ {/* Top Category Tabs (Xiaohongshu App Style - No Left Sidebar) */} +
+ + + + + + + {/* Categories as Horizontal Chips */} + {categories.map((cat) => { + const isActive = filter.categoryId === cat.id; + return ( + + ); + })} +
+ + {/* True Xiaohongshu Waterfall (Masonry Grid with Staggered Heights) */} +
+ {entries.length === 0 ? ( +
+
🎉
+

Inbox Zero

+

No articles to display in this feed.

+
+ ) : ( +
+ {entries.map((entry) => { + const coverImg = extractFirstImage(entry.content || entry.raw_content); + const snippet = stripHtml(entry.content || entry.raw_content, 90); + const relativeDate = formatRelativeDate(entry.published_at); + + return ( +
+
handleCardClick(entry)} + > + {/* Card Cover (Xiaohongshu style image / fallback) */} +
+ {coverImg ? ( + { + (e.target as HTMLElement).style.display = 'none'; + }} + /> + ) : ( +
+
+ + + {entry.feed_title} + +
+
+ )} +
+ + {/* Card Body */} +
+

{entry.title}

+ {snippet &&

{snippet}

} + + {/* Footer */} +
+
+ {entry.feed_title} + • + {relativeDate} +
+ +
+ {!entry.is_read &&
} + +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* Full-Screen Reading View with Back Arrow (Xiaohongshu post reader UX) */} + {selectedEntry && ( +
+ {/* Top Bar with Back Arrow */} +
+ + + + {selectedEntry.feed_title} + + +
+ + + + + + + + + + + +
+
+ + {/* Reading Body */} +
+
+
+ {selectedEntry.feed_title} + • + + {' '} + {new Date(selectedEntry.published_at).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + • + {estimateReadingTime(selectedEntry.content || selectedEntry.raw_content)} + {selectedEntry.author && ( + <> + • + + {selectedEntry.author} + + + )} +
+ +

+ + {selectedEntry.title} + +

+ +
+ + {/* End of article sentinel for auto-read observer */} +
+
+
+
+ )} +
+ ); +}; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..a3d6d9e --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,1132 @@ +/* RSSPipe - Modern Minimal High-Contrast Design System (Light Mode) */ + +:root { + --bg-app: #f8fafc; + --bg-surface: #ffffff; + --bg-sidebar: #f8fafc; + --bg-list: #ffffff; + --bg-reader: #ffffff; + --bg-card: #ffffff; + --bg-card-hover: #f1f5f9; + --bg-card-selected: #e2e8f0; + + --border: #e2e8f0; + --border-light: #f1f5f9; + --border-dark: #cbd5e1; + --border-focus: #0f172a; + + --text-main: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; + --text-dim: #94a3b8; + + --accent: #0f172a; + --accent-hover: #1e293b; + --accent-soft: rgba(15, 23, 42, 0.06); + --tag-bg: #f1f5f9; + --tag-text: #0f172a; + --tag-border: #cbd5e1; + + --star: #d97706; + --star-soft: #fef3c7; + --unread-dot: #0f172a; + --success: #15803d; + --danger: #b91c1c; + + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.1); + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body, #root { + height: 100%; + width: 100%; + overflow: hidden; + font-family: var(--font-sans); + background-color: var(--bg-app); + color: var(--text-main); + -webkit-font-smoothing: antialiased; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 5px; + height: 5px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border-dark); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Base App Layout */ +.app-container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + overflow: hidden; + background: var(--bg-app); +} + +/* Top Navigation Header */ +.app-header { + height: 50px; + min-height: 50px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.85rem; + z-index: 30; + gap: 0.75rem; +} + +.logo-area { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + font-size: 1.05rem; + letter-spacing: -0.02em; + color: var(--text-main); + user-select: none; + cursor: pointer; +} + +.logo-badge { + background: var(--accent); + color: #ffffff; + font-size: 0.62rem; + font-weight: 700; + padding: 2px 5px; + border-radius: 4px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.search-bar-container { + flex: 1; + max-width: 480px; + position: relative; + display: flex; + align-items: center; +} + +.search-input { + width: 100%; + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: 20px; + padding: 0.38rem 0.9rem 0.38rem 2rem; + color: var(--text-main); + font-size: 0.84rem; + outline: none; + transition: all 0.15s ease; +} + +.search-input:focus { + border-color: var(--border-focus); + background: #ffffff; + box-shadow: 0 0 0 2px var(--accent-soft); +} + +.search-icon { + position: absolute; + left: 0.7rem; + color: var(--text-muted); + pointer-events: none; +} + +.search-shortcut-hint { + position: absolute; + right: 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.65rem; + padding: 1px 5px; + color: var(--text-muted); + font-family: var(--font-mono); + pointer-events: none; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +/* Button & Pill styles */ +.btn-icon { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.1s ease; +} + +.btn-icon:hover { + background: var(--bg-card-hover); + color: var(--text-main); + border-color: var(--border); +} + +.btn-icon.active { + background: var(--accent); + color: #ffffff; +} + +.btn-primary { + background: var(--accent); + color: #ffffff; + border: 1px solid var(--accent); + font-weight: 600; + font-size: 0.8rem; + padding: 0.38rem 0.75rem; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + transition: background 0.1s ease; + white-space: nowrap; +} + +.btn-primary:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + font-weight: 500; + font-size: 0.8rem; + padding: 0.38rem 0.75rem; + border-radius: var(--radius-sm); + display: inline-flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + transition: all 0.1s ease; + white-space: nowrap; +} + +.btn-secondary:hover { + background: var(--bg-card-hover); + border-color: var(--border-dark); +} + +.view-mode-pill { + display: flex; + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 2px; + gap: 2px; +} + +.tag-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + background: var(--tag-bg); + color: var(--tag-text); + border: 1px solid var(--tag-border); +} + +/* Mobile Dropdown Menu */ +.mobile-menu-dropdown { + position: absolute; + top: 48px; + right: 10px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + width: 200px; + padding: 0.4rem; + z-index: 50; + display: flex; + flex-direction: column; + gap: 2px; +} + +.mobile-menu-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-weight: 500; + color: var(--text-main); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + width: 100%; +} + +.mobile-menu-item:hover { + background: var(--bg-card-hover); +} + +/* ========================================================================= */ +/* 1. REEDER 4 MINIMAL 3-PANE SPLIT VIEW (DESKTOP) */ +/* ========================================================================= */ + +.three-pane-container { + display: flex; + flex: 1; + height: calc(100vh - 50px); + overflow: hidden; + background: var(--bg-app); +} + +/* Sidebar (Pane 1) - Independent Scrollable */ +.sidebar-pane { + width: 240px; + min-width: 240px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + transition: width 0.2s ease, min-width 0.2s ease; +} + +.sidebar-pane.collapsed { + width: 0; + min-width: 0; + border-right: none; + overflow: hidden; +} + +.sidebar-scroll-area { + flex: 1 1 0%; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0.75rem 0.5rem 2rem 0.5rem; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-nav-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.45rem 0.65rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.825rem; + font-weight: 500; + cursor: pointer; + transition: all 0.1s ease; +} + +.sidebar-nav-item:hover { + background: var(--bg-card-hover); + color: var(--text-main); +} + +.sidebar-nav-item.active { + background: var(--bg-card-selected); + color: var(--text-main); + font-weight: 600; +} + +.sidebar-nav-label { + display: flex; + align-items: center; + gap: 0.55rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-section-header { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + padding: 0.85rem 0.65rem 0.25rem 0.65rem; + display: flex; + align-items: center; + justify-content: space-between; +} + +.feed-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 0.65rem 0.35rem 1.4rem; + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 0.79rem; + cursor: pointer; + transition: all 0.1s ease; +} + +.feed-item:hover { + background: var(--bg-card-hover); + color: var(--text-main); +} + +.feed-item.active { + background: var(--bg-card-selected); + color: var(--text-main); + font-weight: 600; +} + +.feed-title-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.badge { + font-size: 0.68rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 10px; + background: var(--border); + color: var(--text-muted); +} + +.badge.unread { + background: var(--accent); + color: #ffffff; +} + +/* Article List (Pane 2) - Independent Scrollable */ +.article-list-pane { + width: 350px; + min-width: 300px; + max-width: 400px; + background: var(--bg-list); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.article-list-header { + height: 44px; + min-height: 44px; + padding: 0 0.85rem; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); + background: var(--bg-list); +} + +.article-list-scroll-area { + flex: 1 1 0%; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; +} + +.article-item-card { + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--border-light); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.3rem; + position: relative; + transition: background 0.1s ease; + user-select: none; +} + +.article-item-card:hover { + background: var(--bg-card-hover); +} + +.article-item-card.selected { + background: var(--bg-card-selected); +} + +.article-item-card.selected::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--accent); +} + +.article-item-top { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.74rem; + color: var(--text-muted); +} + +.article-item-source { + font-weight: 600; + color: var(--text-main); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.article-item-title { + font-size: 0.88rem; + font-weight: 600; + line-height: 1.35; + color: var(--text-main); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.article-item-card.is-read .article-item-title { + font-weight: 400; + color: var(--text-secondary); +} + +.article-item-snippet { + font-size: 0.76rem; + line-height: 1.4; + color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.unread-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--unread-dot); + flex-shrink: 0; +} + +/* Reader Canvas (Pane 3) - Independent Scrollable */ +.reader-pane { + flex: 1 1 0%; + height: 100%; + background: var(--bg-reader); + overflow-y: auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; +} + +.reader-toolbar { + position: sticky; + top: 0; + height: 44px; + min-height: 44px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); + padding: 0 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + z-index: 10; +} + +.reader-content-canvas { + max-width: 680px; + width: 100%; + margin: 0 auto; + padding: 2rem 1.5rem 5rem 1.5rem; +} + +.reader-source-bar { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 0.85rem; +} + +.reader-headline { + font-size: 1.85rem; + font-weight: 800; + line-height: 1.28; + letter-spacing: -0.02em; + color: var(--text-main); + margin-bottom: 1.5rem; +} + +.reader-headline a { + color: inherit; + text-decoration: none; +} + +.reader-headline a:hover { + text-decoration: underline; +} + +.reader-body { + font-size: 1rem; + line-height: 1.8; + color: var(--text-secondary); + overflow-wrap: break-word; +} + +.reader-body p { + margin-bottom: 1.25rem; +} + +.reader-body h2, .reader-body h3 { + color: var(--text-main); + margin-top: 1.8rem; + margin-bottom: 0.75rem; + line-height: 1.35; +} + +.reader-body img { + max-width: 100%; + height: auto; + border-radius: var(--radius-sm); + margin: 1.25rem 0; + border: 1px solid var(--border); +} + +.reader-body pre { + background: var(--bg-app); + border: 1px solid var(--border); + padding: 1rem; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.85rem; + overflow-x: auto; + margin: 1.25rem 0; +} + +.reader-body a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 3px; +} + +/* ========================================================================= */ +/* 2. TRUE XIAOHONGSHU WATERFALL (MASONRY) VIEW (NO SIDEBAR) */ +/* ========================================================================= */ + +.xhs-waterfall-container { + display: flex; + flex-direction: column; + flex: 1; + height: calc(100vh - 50px); + overflow: hidden; + background: var(--bg-app); +} + +/* Top Horizontal Category Tabs (Xiaohongshu style) */ +.xhs-tabs-bar { + height: 42px; + min-height: 42px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 0.85rem; + overflow-x: auto; + white-space: nowrap; + gap: 0.4rem; + scrollbar-width: none; +} + +.xhs-tabs-bar::-webkit-scrollbar { + display: none; +} + +.xhs-tab-item { + padding: 0.3rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.12s ease; + user-select: none; + display: flex; + align-items: center; + gap: 4px; +} + +.xhs-tab-item:hover { + background: var(--bg-card-hover); + color: var(--text-main); +} + +.xhs-tab-item.active { + background: var(--accent); + color: #ffffff; + font-weight: 600; +} + +.xhs-waterfall-scroll { + flex: 1 1 0%; + overflow-y: auto; + padding: 0.75rem; +} + +/* True CSS / Multi-Column Masonry (Staggered Heights) */ +.xhs-masonry { + column-count: 2; + column-gap: 10px; + max-width: 1400px; + margin: 0 auto; +} + +@media (min-width: 640px) { + .xhs-masonry { + column-count: 3; + column-gap: 12px; + } +} + +@media (min-width: 1024px) { + .xhs-masonry { + column-count: 4; + column-gap: 14px; + } +} + +@media (min-width: 1400px) { + .xhs-masonry { + column-count: 5; + column-gap: 16px; + } +} + +.xhs-masonry-item { + break-inside: avoid; + margin-bottom: 10px; +} + +@media (min-width: 640px) { + .xhs-masonry-item { + margin-bottom: 14px; + } +} + +.xhs-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + display: flex; + flex-direction: column; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.xhs-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--border-dark); +} + +.xhs-card.is-read { + opacity: 0.75; +} + +.xhs-cover-wrap { + width: 100%; + background: var(--bg-app); + overflow: hidden; + position: relative; +} + +.xhs-cover-img { + width: 100%; + display: block; + object-fit: cover; + max-height: 280px; +} + +.xhs-cover-fallback { + width: 100%; + height: 90px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #f1f5f9, #e2e8f0); + color: var(--text-muted); + padding: 0.75rem; + text-align: center; +} + +.xhs-card-body { + padding: 0.65rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.xhs-card-title { + font-size: 0.85rem; + font-weight: 700; + line-height: 1.35; + color: var(--text-main); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.xhs-card-snippet { + font-size: 0.72rem; + line-height: 1.38; + color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.xhs-card-footer { + margin-top: 0.25rem; + padding-top: 0.4rem; + border-top: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.7rem; + color: var(--text-muted); +} + +.xhs-publisher-badge { + font-weight: 600; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 110px; +} + +/* Mobile Post Reader (Full Page Slide with Back Arrow) */ +.mobile-post-reader { + position: fixed; + inset: 0; + background: #ffffff; + z-index: 100; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.mobile-post-reader-header { + height: 48px; + min-height: 48px; + background: #ffffff; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.85rem; + z-index: 10; +} + +.mobile-post-reader-content { + flex: 1 1 0%; + overflow-y: auto; + padding: 1.25rem 1rem 5rem 1rem; +} + +/* ========================================================================= */ +/* 3. SETTINGS VIEW PAGE - BEAUTIFUL, MODERN, RESPONSIVE */ +/* ========================================================================= */ + +.settings-container { + display: flex; + flex: 1; + height: calc(100vh - 50px); + overflow: hidden; + background: var(--bg-app); +} + +@media (max-width: 768px) { + .settings-container { + flex-direction: column; + } +} + +.settings-nav { + width: 220px; + min-width: 220px; + background: var(--bg-surface); + border-right: 1px solid var(--border); + padding: 1rem 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +@media (max-width: 768px) { + .settings-nav { + width: 100%; + min-width: 100%; + height: 44px; + min-height: 44px; + padding: 0 0.5rem; + flex-direction: row; + border-right: none; + border-bottom: 1px solid var(--border); + overflow-x: auto; + white-space: nowrap; + align-items: center; + } +} + +.settings-nav-item { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.825rem; + font-weight: 500; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.1s ease; + white-space: nowrap; +} + +.settings-nav-item:hover { + background: var(--bg-card-hover); + color: var(--text-main); +} + +.settings-nav-item.active { + background: var(--accent); + color: #ffffff; + font-weight: 600; +} + +.settings-content-area { + flex: 1 1 0%; + width: 100%; + overflow-y: auto; + padding: 1.25rem 1.5rem; +} + +@media (min-width: 1024px) { + .settings-content-area { + padding: 1.75rem 2.25rem; + } +} + +.settings-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1.25rem; + box-shadow: var(--shadow-sm); + margin-bottom: 1.5rem; + width: 100%; +} + +@media (min-width: 768px) { + .settings-card { + padding: 1.75rem; + } +} + +.settings-card-title { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 0.3rem; + color: var(--text-main); +} + +.settings-card-subtitle { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 1.25rem; +} + +/* Modals & Overlays */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(3px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1rem; +} + +.modal-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + width: 100%; + max-width: 520px; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.modal-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h3 { + font-size: 1rem; + font-weight: 700; +} + +.modal-body { + padding: 1.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-label { + display: block; + font-size: 0.76rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.35rem; +} + +.form-input, .form-select { + width: 100%; + background: var(--bg-surface); + border: 1px solid var(--border-dark); + border-radius: var(--radius-sm); + padding: 0.45rem 0.7rem; + color: var(--text-main); + font-size: 0.825rem; + outline: none; +} + +.form-input:focus, .form-select:focus { + border-color: var(--border-focus); +} + +/* Empty State */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: var(--text-muted); + padding: 3rem 1.5rem; +} + +.empty-state-icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.empty-state h3 { + font-size: 1.1rem; + font-weight: 700; + color: var(--text-main); + margin-bottom: 0.3rem; +} + +/* Login Screen */ +.login-container { + height: 100vh; + width: 100vw; + display: flex; + align-items: center; + justify-content: center; + background: #f1f5f9; + padding: 1rem; +} + +.login-card { + width: 100%; + max-width: 380px; + background: #ffffff; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 2.25rem 1.75rem; + box-shadow: var(--shadow-md); +} + +.login-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + font-size: 1.4rem; + font-weight: 800; + margin-bottom: 0.35rem; +} + +.login-subtitle { + text-align: center; + font-size: 0.825rem; + color: var(--text-muted); + margin-bottom: 1.75rem; +} + +.alert-danger { + background: #fef2f2; + border: 1px solid #fecaca; + color: var(--danger); + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.8rem; + margin-bottom: 1.1rem; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3605525 --- /dev/null +++ b/web/src/main.tsx @@ -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( + + + , +) diff --git a/web/src/services/api.ts b/web/src/services/api.ts new file mode 100644 index 0000000..efbed79 --- /dev/null +++ b/web/src/services/api.ts @@ -0,0 +1,195 @@ +import { Category, Feed, FeedEntry, Stats, User } from '../types'; + +const API_BASE = '/api'; + +async function request(path: string, options: RequestInit = {}): Promise { + const token = localStorage.getItem('rsspipe_jwt'); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record || {}), + }; + + 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 { + try { + await request('/auth/logout', { method: 'POST' }); + } finally { + localStorage.removeItem('rsspipe_jwt'); + } + }, + + async getMe(): Promise { + return request('/auth/me'); + }, + + // Stats + async getStats(): Promise { + return request('/stats'); + }, + + // Categories + async listCategories(): Promise { + return request('/categories'); + }, + + async createCategory(title: string): Promise { + return request('/categories', { + method: 'POST', + body: JSON.stringify({ title }), + }); + }, + + async deleteCategory(id: string): Promise { + return request(`/categories/${id}`, { method: 'DELETE' }); + }, + + // Feeds + async listFeeds(): Promise { + return request('/feeds'); + }, + + async createFeed(feed_url: string, category_id?: string, title?: string): Promise { + return request('/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 { + return request(`/feeds/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + }, + + async deleteFeed(id: string): Promise { + return request(`/feeds/${id}`, { method: 'DELETE' }); + }, + + async refreshFeed(id: string): Promise { + return request(`/feeds/${id}/refresh`, { method: 'POST' }); + }, + + async refreshAllFeeds(): Promise { + return request('/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 { + return request(`/entries/${id}/read`, { + method: 'PUT', + body: JSON.stringify({ is_read }), + }); + }, + + async updateStar(id: string, is_starred: boolean): Promise { + return request(`/entries/${id}/star`, { + method: 'PUT', + body: JSON.stringify({ is_starred }), + }); + }, + + async markAllRead(feed_id?: string, category_id?: string): Promise { + 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 = {}; + 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 || ''}`; + } +}; diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..c3b3336 --- /dev/null +++ b/web/src/types.ts @@ -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; + 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; +} diff --git a/web/src/utils.ts b/web/src/utils.ts new file mode 100644 index 0000000..cf9c08d --- /dev/null +++ b/web/src/utils.ts @@ -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(/]+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`; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a4c834a --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1731983 --- /dev/null +++ b/web/vite.config.ts @@ -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, + } + } + } +})