From 513576b90eb484473e334427308da881283d3b27 Mon Sep 17 00:00:00 2001 From: Gemini Agent Date: Sat, 17 Jan 2026 12:19:57 +0000 Subject: [PATCH] v2.0: Major feature update New Features: - API key authentication for external access - Apple Shortcuts integration endpoint (/api/v1/add) - Full-text search across all articles - Folders for organizing articles - Highlights and notes on articles - Reading stats with streaks - Reading goals (daily/weekly/monthly) - Import from Pocket/Instapaper - RSS feed output - PWA support for mobile - Auto theme scheduling (day/night) - Settings page with all configuration API Endpoints: - /api/v1/add - Add articles via API key - /api/keys - Manage API keys - /api/search - Full-text search - /api/folders - Folder management - /api/highlights - Highlights/notes - /api/stats - Reading statistics - /api/goals - Reading goals - /api/import - Pocket/Instapaper import - /api/rss - RSS feed Co-Authored-By: Claude Opus 4.5 --- data/readlater.db-shm | Bin 32768 -> 32768 bytes data/readlater.db-wal | Bin 49472 -> 131872 bytes drizzle/0001_watery_the_santerians.sql | 67 +++ drizzle/meta/0001_snapshot.json | 559 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + public/icons/icon.svg | 6 + public/manifest.json | 37 ++ src/app/api/folders/route.ts | 94 +++++ src/app/api/goals/route.ts | 103 +++++ src/app/api/highlights/route.ts | 95 +++++ src/app/api/import/route.ts | 137 ++++++ src/app/api/keys/route.ts | 49 +++ src/app/api/rss/route.ts | 63 +++ src/app/api/search/route.ts | 37 ++ src/app/api/stats/route.ts | 129 ++++++ src/app/api/v1/add/route.ts | 145 +++++++ src/app/layout.tsx | 24 +- src/app/page.tsx | 207 +++++++-- src/app/settings/page.tsx | 477 +++++++++++++++++++++ src/lib/auth.ts | 85 ++++ src/lib/db/schema.ts | 93 +++- src/lib/types.ts | 47 +++ 22 files changed, 2431 insertions(+), 30 deletions(-) create mode 100644 drizzle/0001_watery_the_santerians.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.json create mode 100644 src/app/api/folders/route.ts create mode 100644 src/app/api/goals/route.ts create mode 100644 src/app/api/highlights/route.ts create mode 100644 src/app/api/import/route.ts create mode 100644 src/app/api/keys/route.ts create mode 100644 src/app/api/rss/route.ts create mode 100644 src/app/api/search/route.ts create mode 100644 src/app/api/stats/route.ts create mode 100644 src/app/api/v1/add/route.ts create mode 100644 src/app/settings/page.tsx create mode 100644 src/lib/auth.ts diff --git a/data/readlater.db-shm b/data/readlater.db-shm index e17db309a40f1b659ecd1eb56c4b1228e10dcb7e..2f0eb39268c149e71e6664c587e3f4cb115d1bd1 100644 GIT binary patch delta 349 zcmb8oJq`gu7{>8=N9Ak3vA#FaP*6#L?b-= zhD2+U-#;^(op-`ehM}DKx2E_m62b~_$iv}S9WGX*^~Bw8ueIrH)0&KX?RxXy|G%@N zxv!t~`7TAUoOcFzSxAUM91@U(6r{lb6D+X70T(>TKo)Y~Ltg1qm&!^vC`KZ!^qHs< woybu7LR96w$@?JM?;icTL6*`_NjBHvx?GQ&;byrxuFu6|xK-d5xut028>ARqc>n+a delta 167 zcmZo@U}|V!s+V}A%K!t63=9H1Kn@2G|4O>o66jSnawqzV9S!!aoU diff --git a/data/readlater.db-wal b/data/readlater.db-wal index 717a694c27164cefd378f159f56c13a67745149d..a51320b978bcf177b702ca38331db25b5ad12778 100644 GIT binary patch delta 3067 zcmbVOTWl0n7@p~NclOfll-{j>|NG8={@F8gmNyUBPpvyULJ$NAFCyFW>HFU=&lg7HbLR)FpF1{hiYuDL$eG*> z&={6zBI@dhbqoWcR(N&7%lM;@h-}zOtUCOUyWtYiM(!hM3;hAPukl;gUkxW+2c56E zHaaQ$RmTt18}|2XDkWGcTcrLu%fa zrztZMr%FO5EviXINo&%ylw(F>j}A}8CmBCP%9)fX32GkyQIAfMS)ElRV7FjWJZ%85Xe zxO`f9CNEV5Jmqwz5~G`@V$5h_B(`tDmLsm*__bLjwo~prQcGXD6>5DeoJmcPlCb|RQ3RNyp_1a#Xp^IOx>{!t%~(Tt=4R5v$ms2c)z(P;1=m z2r`6iw0R!u)yrVCq5#&w(sOfhkBjnjb&>NiWJRgbsO1}7Z9-`jIgkc3wYIyqES8T1 zo_LV-T8@|6uX&v2KnyigXy z13-&o_LZOi!JPMH*Urks}$k? zIA_S)u)_xJL*C*UZ~^?yJxx&_wwo-vkjWscaPSMG>i^0g17pZl(^MmhO(^+j1auD{ zhHTKhpcQ@g=EBH417O@8)uxq)^)lXemonB_@B9R$fU0qM73=J^IuUYczy?2*-R*|v zfRT!mzCqqxY;pHndh7Hb7RK+9rxU-O{CIL*j~kBF(K10V((+=F*+v(}>a0Z3Z5{H9 zK@broSsaRn2K@q%gh*JB`LI727z+8>h%hJx#GoHUheAR?3=N4g8w`d+{9u>`e3&PX zUvyu-HAW&bUY4SQEJRtB7yLj906~cGVGsp@s1S&V0+54XFd|0TKya-;5|G285DSKc zh&TvXelQdk$m17I4}Sx5wcsKS`}%CZywGvTi9}o{=v(x4DB=tH$e9)1dJ93;SuBJD z2KB2V{aEMiW6#scUR^rTRK~CA(n%_qL;X&%EBWsUG`NIu;g}8mlz&HTWTgqyfMd>e z-rzUz%}u7Y(5tZ(b=E>Uc!^7xcp7mY@xmYXeRBEjb0!RpV=k_F{Hq1-r+L#_O*m$~ zJ%8f?d1~Cmvkb>%dR}r~%^b~|c$#s{`R`uY{pCmE?TIOd)0yLRMn-`rxtbl{lm h&*xA5?fd$5(^~q^@=jyFCVEbPc8q)V#VVfF{{aUT-)R5< delta 9 QcmZ3`#&MvDd4t1202FuxB>(^b diff --git a/drizzle/0001_watery_the_santerians.sql b/drizzle/0001_watery_the_santerians.sql new file mode 100644 index 0000000..658f257 --- /dev/null +++ b/drizzle/0001_watery_the_santerians.sql @@ -0,0 +1,67 @@ +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `key` text NOT NULL, + `last_used` integer, + `created_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `api_keys_key_unique` ON `api_keys` (`key`);--> statement-breakpoint +CREATE TABLE `email_config` ( + `id` text PRIMARY KEY NOT NULL, + `inbox_email` text, + `is_active` integer DEFAULT true, + `created_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `email_config_inbox_email_unique` ON `email_config` (`inbox_email`);--> statement-breakpoint +CREATE TABLE `folders` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `color` text DEFAULT '#3b82f6', + `icon` text DEFAULT 'folder', + `parent_id` text, + `sort_order` integer DEFAULT 0, + `created_at` integer +); +--> statement-breakpoint +CREATE TABLE `highlights` ( + `id` text PRIMARY KEY NOT NULL, + `article_id` text NOT NULL, + `text` text NOT NULL, + `note` text, + `color` text DEFAULT '#fbbf24', + `start_offset` integer, + `end_offset` integer, + `created_at` integer +); +--> statement-breakpoint +CREATE TABLE `reading_goals` ( + `id` text PRIMARY KEY NOT NULL, + `type` text NOT NULL, + `metric` text NOT NULL, + `target` integer NOT NULL, + `is_active` integer DEFAULT true, + `created_at` integer +); +--> statement-breakpoint +CREATE TABLE `reading_stats` ( + `id` text PRIMARY KEY NOT NULL, + `date` text NOT NULL, + `articles_read` integer DEFAULT 0, + `articles_added` integer DEFAULT 0, + `words_read` integer DEFAULT 0, + `time_spent_seconds` integer DEFAULT 0, + `streak` integer DEFAULT 0 +); +--> statement-breakpoint +CREATE UNIQUE INDEX `reading_stats_date_unique` ON `reading_stats` (`date`);--> statement-breakpoint +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer +); +--> statement-breakpoint +ALTER TABLE `articles` ADD `reading_time_seconds` integer DEFAULT 0;--> statement-breakpoint +ALTER TABLE `articles` ADD `folder_id` text;--> statement-breakpoint +ALTER TABLE `articles` ADD `finished_at` integer; \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..1b0c27d --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,559 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d3369a08-d474-468e-a003-df32d5f2c61d", + "prevId": "49bb8830-0860-4592-97d9-83c44fd350fc", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_unique": { + "name": "api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "articles": { + "name": "articles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lead_image": { + "name": "lead_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "reading_progress": { + "name": "reading_progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "reading_time_seconds": { + "name": "reading_time_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_favorite": { + "name": "is_favorite", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_config": { + "name": "email_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inbox_email": { + "name": "inbox_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_config_inbox_email_unique": { + "name": "email_config_inbox_email_unique", + "columns": [ + "inbox_email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#3b82f6'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'folder'" + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#fbbf24'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reading_goals": { + "name": "reading_goals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reading_stats": { + "name": "reading_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articles_read": { + "name": "articles_read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "articles_added": { + "name": "articles_added", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "words_read": { + "name": "words_read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak": { + "name": "streak", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "reading_stats_date_unique": { + "name": "reading_stats_date_unique", + "columns": [ + "date" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 16913e4..d48004a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1768632959441, "tag": "0000_black_lucky_pierre", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1768638242044, + "tag": "0001_watery_the_santerians", + "breakpoints": true } ] } \ No newline at end of file diff --git a/public/icons/icon.svg b/public/icons/icon.svg new file mode 100644 index 0000000..75b631e --- /dev/null +++ b/public/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..74f3487 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "ReadLater", + "short_name": "ReadLater", + "description": "Save articles and read them later with text-to-speech", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "utilities"], + "shortcuts": [ + { + "name": "Add Article", + "url": "/?action=add", + "description": "Add a new article" + }, + { + "name": "Favorites", + "url": "/?filter=favorites", + "description": "View favorite articles" + } + ] +} diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts new file mode 100644 index 0000000..d8475dc --- /dev/null +++ b/src/app/api/folders/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { eq, asc } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// GET /api/folders - List all folders +export async function GET() { + try { + const folders = await db + .select() + .from(schema.folders) + .orderBy(asc(schema.folders.sortOrder)); + return NextResponse.json(folders); + } catch (error) { + console.error("Error listing folders:", error); + return NextResponse.json({ error: "Failed to list folders" }, { status: 500 }); + } +} + +// POST /api/folders - Create folder +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, color, icon, parentId } = body; + + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + const id = uuidv4(); + await db.insert(schema.folders).values({ + id, + name, + color: color || "#3b82f6", + icon: icon || "folder", + parentId: parentId || null, + }); + + const folder = await db.select().from(schema.folders).where(eq(schema.folders.id, id)).limit(1); + return NextResponse.json(folder[0], { status: 201 }); + } catch (error) { + console.error("Error creating folder:", error); + return NextResponse.json({ error: "Failed to create folder" }, { status: 500 }); + } +} + +// PATCH /api/folders - Update folder +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { id, name, color, icon, sortOrder } = body; + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + const updates: Partial = {}; + if (name) updates.name = name; + if (color) updates.color = color; + if (icon) updates.icon = icon; + if (sortOrder !== undefined) updates.sortOrder = sortOrder; + + await db.update(schema.folders).set(updates).where(eq(schema.folders.id, id)); + + const folder = await db.select().from(schema.folders).where(eq(schema.folders.id, id)).limit(1); + return NextResponse.json(folder[0]); + } catch (error) { + console.error("Error updating folder:", error); + return NextResponse.json({ error: "Failed to update folder" }, { status: 500 }); + } +} + +// DELETE /api/folders?id=... +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + // Remove folder reference from articles + await db.update(schema.articles).set({ folderId: null }).where(eq(schema.articles.folderId, id)); + + // Delete folder + await db.delete(schema.folders).where(eq(schema.folders.id, id)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting folder:", error); + return NextResponse.json({ error: "Failed to delete folder" }, { status: 500 }); + } +} diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts new file mode 100644 index 0000000..94eeaa4 --- /dev/null +++ b/src/app/api/goals/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { eq } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// GET /api/goals - List all goals +export async function GET() { + try { + const goals = await db.select().from(schema.readingGoals); + return NextResponse.json(goals); + } catch (error) { + console.error("Error listing goals:", error); + return NextResponse.json({ error: "Failed to list goals" }, { status: 500 }); + } +} + +// POST /api/goals - Create goal +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { type, metric, target } = body; + + if (!type || !metric || !target) { + return NextResponse.json( + { error: "type, metric, and target are required" }, + { status: 400 } + ); + } + + // Validate type + if (!["daily", "weekly", "monthly"].includes(type)) { + return NextResponse.json( + { error: "type must be daily, weekly, or monthly" }, + { status: 400 } + ); + } + + // Validate metric + if (!["articles", "words", "time"].includes(metric)) { + return NextResponse.json( + { error: "metric must be articles, words, or time" }, + { status: 400 } + ); + } + + const id = uuidv4(); + await db.insert(schema.readingGoals).values({ + id, + type, + metric, + target, + isActive: true, + }); + + const goal = await db.select().from(schema.readingGoals).where(eq(schema.readingGoals.id, id)).limit(1); + return NextResponse.json(goal[0], { status: 201 }); + } catch (error) { + console.error("Error creating goal:", error); + return NextResponse.json({ error: "Failed to create goal" }, { status: 500 }); + } +} + +// PATCH /api/goals - Update goal +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { id, target, isActive } = body; + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + const updates: Partial = {}; + if (target !== undefined) updates.target = target; + if (isActive !== undefined) updates.isActive = isActive; + + await db.update(schema.readingGoals).set(updates).where(eq(schema.readingGoals.id, id)); + + const goal = await db.select().from(schema.readingGoals).where(eq(schema.readingGoals.id, id)).limit(1); + return NextResponse.json(goal[0]); + } catch (error) { + console.error("Error updating goal:", error); + return NextResponse.json({ error: "Failed to update goal" }, { status: 500 }); + } +} + +// DELETE /api/goals?id=... +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + await db.delete(schema.readingGoals).where(eq(schema.readingGoals.id, id)); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting goal:", error); + return NextResponse.json({ error: "Failed to delete goal" }, { status: 500 }); + } +} diff --git a/src/app/api/highlights/route.ts b/src/app/api/highlights/route.ts new file mode 100644 index 0000000..2005d1d --- /dev/null +++ b/src/app/api/highlights/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { eq, desc } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// GET /api/highlights?articleId=... - Get highlights for an article +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const articleId = searchParams.get("articleId"); + + let query = db.select().from(schema.highlights).orderBy(desc(schema.highlights.createdAt)); + + if (articleId) { + query = query.where(eq(schema.highlights.articleId, articleId)) as typeof query; + } + + const highlights = await query.limit(100); + return NextResponse.json(highlights); + } catch (error) { + console.error("Error listing highlights:", error); + return NextResponse.json({ error: "Failed to list highlights" }, { status: 500 }); + } +} + +// POST /api/highlights - Create highlight +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { articleId, text, note, color, startOffset, endOffset } = body; + + if (!articleId || !text) { + return NextResponse.json({ error: "articleId and text are required" }, { status: 400 }); + } + + const id = uuidv4(); + await db.insert(schema.highlights).values({ + id, + articleId, + text, + note: note || null, + color: color || "#fbbf24", + startOffset: startOffset || null, + endOffset: endOffset || null, + }); + + const highlight = await db.select().from(schema.highlights).where(eq(schema.highlights.id, id)).limit(1); + return NextResponse.json(highlight[0], { status: 201 }); + } catch (error) { + console.error("Error creating highlight:", error); + return NextResponse.json({ error: "Failed to create highlight" }, { status: 500 }); + } +} + +// PATCH /api/highlights - Update highlight (note) +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { id, note, color } = body; + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + const updates: Partial = {}; + if (note !== undefined) updates.note = note; + if (color) updates.color = color; + + await db.update(schema.highlights).set(updates).where(eq(schema.highlights.id, id)); + + const highlight = await db.select().from(schema.highlights).where(eq(schema.highlights.id, id)).limit(1); + return NextResponse.json(highlight[0]); + } catch (error) { + console.error("Error updating highlight:", error); + return NextResponse.json({ error: "Failed to update highlight" }, { status: 500 }); + } +} + +// DELETE /api/highlights?id=... +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + await db.delete(schema.highlights).where(eq(schema.highlights.id, id)); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting highlight:", error); + return NextResponse.json({ error: "Failed to delete highlight" }, { status: 500 }); + } +} diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts new file mode 100644 index 0000000..04846cb --- /dev/null +++ b/src/app/api/import/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { extractArticle } from "@/lib/utils/extract"; +import { eq } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +interface ImportItem { + url: string; + title?: string; + tags?: string[]; + favorite?: boolean; + archived?: boolean; + addedAt?: string; +} + +// POST /api/import - Import from Pocket/Instapaper export +export async function POST(request: NextRequest) { + try { + const contentType = request.headers.get("content-type") || ""; + + let items: ImportItem[] = []; + + if (contentType.includes("application/json")) { + // JSON import (Pocket API format or custom) + const body = await request.json(); + items = body.items || body.list || []; + + // Handle Pocket export format + if (body.list && typeof body.list === "object" && !Array.isArray(body.list)) { + items = Object.values(body.list).map((item: any) => ({ + url: item.given_url || item.resolved_url, + title: item.given_title || item.resolved_title, + tags: item.tags ? Object.keys(item.tags) : [], + favorite: item.favorite === "1", + archived: item.status === "1", + addedAt: item.time_added ? new Date(parseInt(item.time_added) * 1000).toISOString() : undefined, + })); + } + } else if (contentType.includes("text/html")) { + // HTML import (Pocket/Instapaper HTML export) + const html = await request.text(); + const urlRegex = /]+href="([^"]+)"[^>]*>([^<]*)<\/a>/gi; + let match; + + while ((match = urlRegex.exec(html)) !== null) { + const url = match[1]; + const title = match[2]; + if (url.startsWith("http")) { + items.push({ url, title }); + } + } + } else if (contentType.includes("text/csv")) { + // CSV import + const csv = await request.text(); + const lines = csv.split("\n").slice(1); // Skip header + + for (const line of lines) { + const parts = line.split(",").map((p) => p.trim().replace(/^"|"$/g, "")); + if (parts[0]?.startsWith("http")) { + items.push({ + url: parts[0], + title: parts[1] || undefined, + tags: parts[2] ? parts[2].split(";") : undefined, + }); + } + } + } else { + return NextResponse.json( + { error: "Unsupported content type. Use application/json, text/html, or text/csv" }, + { status: 400 } + ); + } + + if (items.length === 0) { + return NextResponse.json({ error: "No items found to import" }, { status: 400 }); + } + + const results = { + total: items.length, + imported: 0, + skipped: 0, + failed: 0, + errors: [] as string[], + }; + + // Process items in batches + for (const item of items) { + try { + // Check if already exists + const existing = await db + .select() + .from(schema.articles) + .where(eq(schema.articles.url, item.url)) + .limit(1); + + if (existing.length > 0) { + results.skipped++; + continue; + } + + // Extract article + const extracted = await extractArticle(item.url); + + const id = uuidv4(); + await db.insert(schema.articles).values({ + id, + url: item.url, + title: item.title || extracted.title, + author: extracted.author, + siteName: extracted.siteName, + excerpt: extracted.excerpt, + content: extracted.content, + textContent: extracted.textContent, + leadImage: extracted.leadImage, + wordCount: extracted.wordCount, + tags: item.tags ? JSON.stringify(item.tags) : "[]", + isFavorite: item.favorite || false, + isArchived: item.archived || false, + createdAt: item.addedAt ? new Date(item.addedAt) : new Date(), + }); + + results.imported++; + } catch (error) { + results.failed++; + results.errors.push(`${item.url}: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + + return NextResponse.json(results); + } catch (error) { + console.error("Import error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Import failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts new file mode 100644 index 0000000..7db95a6 --- /dev/null +++ b/src/app/api/keys/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createApiKey, listApiKeys, deleteApiKey } from "@/lib/auth"; + +// GET /api/keys - List all API keys +export async function GET() { + try { + const keys = await listApiKeys(); + return NextResponse.json(keys); + } catch (error) { + console.error("Error listing API keys:", error); + return NextResponse.json({ error: "Failed to list API keys" }, { status: 500 }); + } +} + +// POST /api/keys - Create new API key +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name } = body; + + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + const key = await createApiKey(name); + return NextResponse.json(key, { status: 201 }); + } catch (error) { + console.error("Error creating API key:", error); + return NextResponse.json({ error: "Failed to create API key" }, { status: 500 }); + } +} + +// DELETE /api/keys - Delete API key +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "ID is required" }, { status: 400 }); + } + + await deleteApiKey(id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting API key:", error); + return NextResponse.json({ error: "Failed to delete API key" }, { status: 500 }); + } +} diff --git a/src/app/api/rss/route.ts b/src/app/api/rss/route.ts new file mode 100644 index 0000000..3f4b416 --- /dev/null +++ b/src/app/api/rss/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { desc, eq } from "drizzle-orm"; + +// GET /api/rss - RSS feed of reading list +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const filter = searchParams.get("filter") || "all"; // all, favorites, archived + + let query = db.select().from(schema.articles); + + if (filter === "favorites") { + query = query.where(eq(schema.articles.isFavorite, true)) as typeof query; + } else if (filter === "archived") { + query = query.where(eq(schema.articles.isArchived, true)) as typeof query; + } else { + query = query.where(eq(schema.articles.isArchived, false)) as typeof query; + } + + const articles = await query.orderBy(desc(schema.articles.createdAt)).limit(50); + + // Get base URL from request + const baseUrl = new URL(request.url).origin; + + // Generate RSS XML + const rss = ` + + + ReadLater - ${filter === "favorites" ? "Favorites" : filter === "archived" ? "Archived" : "Reading List"} + ${baseUrl} + Your saved articles from ReadLater + en-us + ${new Date().toUTCString()} + + ${articles + .map( + (article) => ` + + <![CDATA[${article.title}]]> + ${article.url} + ${article.id} + ${new Date(article.createdAt || Date.now()).toUTCString()} + + ${article.author ? `${article.author}` : ""} + ${article.siteName ? `${article.siteName}` : ""} + ` + ) + .join("")} + +`; + + return new NextResponse(rss, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=300", // Cache for 5 minutes + }, + }); + } catch (error) { + console.error("Error generating RSS:", error); + return NextResponse.json({ error: "Failed to generate RSS" }, { status: 500 }); + } +} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..e26cf8f --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { like, or, desc } from "drizzle-orm"; + +// GET /api/search?q=query +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("q"); + + if (!query || query.length < 2) { + return NextResponse.json({ error: "Query must be at least 2 characters" }, { status: 400 }); + } + + const searchTerm = `%${query}%`; + + const results = await db + .select() + .from(schema.articles) + .where( + or( + like(schema.articles.title, searchTerm), + like(schema.articles.textContent, searchTerm), + like(schema.articles.author, searchTerm), + like(schema.articles.siteName, searchTerm), + like(schema.articles.tags, searchTerm) + ) + ) + .orderBy(desc(schema.articles.createdAt)) + .limit(50); + + return NextResponse.json(results); + } catch (error) { + console.error("Search error:", error); + return NextResponse.json({ error: "Search failed" }, { status: 500 }); + } +} diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts new file mode 100644 index 0000000..cba7db6 --- /dev/null +++ b/src/app/api/stats/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { eq, desc, gte, sql } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; + +// GET /api/stats - Get reading stats +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const days = parseInt(searchParams.get("days") || "30"); + + // Get date range + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const startDateStr = startDate.toISOString().split("T")[0]; + + // Get daily stats + const dailyStats = await db + .select() + .from(schema.readingStats) + .where(gte(schema.readingStats.date, startDateStr)) + .orderBy(desc(schema.readingStats.date)); + + // Calculate totals + const totals = dailyStats.reduce( + (acc, day) => ({ + articlesRead: acc.articlesRead + (day.articlesRead || 0), + articlesAdded: acc.articlesAdded + (day.articlesAdded || 0), + wordsRead: acc.wordsRead + (day.wordsRead || 0), + timeSpentSeconds: acc.timeSpentSeconds + (day.timeSpentSeconds || 0), + }), + { articlesRead: 0, articlesAdded: 0, wordsRead: 0, timeSpentSeconds: 0 } + ); + + // Calculate current streak + let streak = 0; + const today = new Date().toISOString().split("T")[0]; + const sortedDays = [...dailyStats].sort((a, b) => b.date.localeCompare(a.date)); + + for (const day of sortedDays) { + if (day.articlesRead && day.articlesRead > 0) { + streak++; + } else if (day.date !== today) { + break; + } + } + + // Get article counts + const articleCounts = await db + .select({ + total: sql`count(*)`, + favorites: sql`sum(case when is_favorite = 1 then 1 else 0 end)`, + archived: sql`sum(case when is_archived = 1 then 1 else 0 end)`, + unread: sql`sum(case when reading_progress < 100 and is_archived = 0 then 1 else 0 end)`, + }) + .from(schema.articles); + + return NextResponse.json({ + period: { days, startDate: startDateStr }, + totals, + streak, + daily: dailyStats, + library: articleCounts[0], + }); + } catch (error) { + console.error("Error getting stats:", error); + return NextResponse.json({ error: "Failed to get stats" }, { status: 500 }); + } +} + +// POST /api/stats - Record reading activity +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { articleId, wordsRead, timeSpentSeconds, completed } = body; + + const today = new Date().toISOString().split("T")[0]; + + // Get or create today's stats + const existing = await db + .select() + .from(schema.readingStats) + .where(eq(schema.readingStats.date, today)) + .limit(1); + + if (existing.length > 0) { + await db + .update(schema.readingStats) + .set({ + articlesRead: (existing[0].articlesRead || 0) + (completed ? 1 : 0), + wordsRead: (existing[0].wordsRead || 0) + (wordsRead || 0), + timeSpentSeconds: (existing[0].timeSpentSeconds || 0) + (timeSpentSeconds || 0), + }) + .where(eq(schema.readingStats.date, today)); + } else { + await db.insert(schema.readingStats).values({ + id: uuidv4(), + date: today, + articlesRead: completed ? 1 : 0, + wordsRead: wordsRead || 0, + timeSpentSeconds: timeSpentSeconds || 0, + }); + } + + // Update article reading time if articleId provided + if (articleId && timeSpentSeconds) { + const article = await db + .select() + .from(schema.articles) + .where(eq(schema.articles.id, articleId)) + .limit(1); + + if (article.length > 0) { + await db + .update(schema.articles) + .set({ + readingTimeSeconds: (article[0].readingTimeSeconds || 0) + timeSpentSeconds, + finishedAt: completed ? new Date() : article[0].finishedAt, + }) + .where(eq(schema.articles.id, articleId)); + } + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error recording stats:", error); + return NextResponse.json({ error: "Failed to record stats" }, { status: 500 }); + } +} diff --git a/src/app/api/v1/add/route.ts b/src/app/api/v1/add/route.ts new file mode 100644 index 0000000..ea17a9e --- /dev/null +++ b/src/app/api/v1/add/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, schema } from "@/lib/db"; +import { extractArticle } from "@/lib/utils/extract"; +import { validateApiKey, extractApiKey } from "@/lib/auth"; +import { v4 as uuidv4 } from "uuid"; +import { eq } from "drizzle-orm"; + +// POST /api/v1/add - Add article (for Apple Shortcuts) +// Accepts: { url: string, tags?: string[], folderId?: string } +// Auth: Bearer token or X-API-Key header +export async function POST(request: NextRequest) { + try { + // Validate API key + const apiKey = extractApiKey(request.headers); + if (!apiKey) { + return NextResponse.json( + { success: false, error: "Missing API key. Use Authorization: Bearer or X-API-Key header." }, + { status: 401 } + ); + } + + const keyRecord = await validateApiKey(apiKey); + if (!keyRecord) { + return NextResponse.json( + { success: false, error: "Invalid API key" }, + { status: 401 } + ); + } + + // Parse request body + const body = await request.json(); + const { url, tags, folderId } = body; + + if (!url) { + return NextResponse.json( + { success: false, error: "URL is required" }, + { status: 400 } + ); + } + + // Check if article already exists + const existing = await db + .select() + .from(schema.articles) + .where(eq(schema.articles.url, url)) + .limit(1); + + if (existing.length > 0) { + return NextResponse.json({ + success: true, + message: "Article already saved", + article: { + id: existing[0].id, + title: existing[0].title, + url: existing[0].url, + }, + }); + } + + // Extract article content + const extracted = await extractArticle(url); + + const id = uuidv4(); + const newArticle: schema.NewArticle = { + id, + url, + title: extracted.title, + author: extracted.author, + siteName: extracted.siteName, + excerpt: extracted.excerpt, + content: extracted.content, + textContent: extracted.textContent, + leadImage: extracted.leadImage, + wordCount: extracted.wordCount, + tags: tags ? JSON.stringify(tags) : "[]", + folderId: folderId || null, + }; + + await db.insert(schema.articles).values(newArticle); + + // Update daily stats + const today = new Date().toISOString().split("T")[0]; + const existingStats = await db + .select() + .from(schema.readingStats) + .where(eq(schema.readingStats.date, today)) + .limit(1); + + if (existingStats.length > 0) { + await db + .update(schema.readingStats) + .set({ articlesAdded: (existingStats[0].articlesAdded || 0) + 1 }) + .where(eq(schema.readingStats.date, today)); + } else { + await db.insert(schema.readingStats).values({ + id: uuidv4(), + date: today, + articlesAdded: 1, + }); + } + + return NextResponse.json({ + success: true, + message: "Article saved", + article: { + id, + title: extracted.title, + url, + wordCount: extracted.wordCount, + readingTime: Math.ceil(extracted.wordCount / 200), + }, + }); + } catch (error) { + console.error("Error saving article:", error); + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : "Failed to save article", + }, + { status: 500 } + ); + } +} + +// GET /api/v1/add?url=... - Alternative for simpler shortcuts +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get("url"); + + if (!url) { + return NextResponse.json( + { success: false, error: "URL parameter required" }, + { status: 400 } + ); + } + + // Create a mock request with the URL in the body + const mockRequest = new NextRequest(request.url, { + method: "POST", + headers: request.headers, + body: JSON.stringify({ url }), + }); + + return POST(mockRequest); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c328178..9483c23 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,27 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "ReadLater - Save and Read Articles", description: "Self-hosted read-it-later app with text-to-speech", + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: "ReadLater", + }, + icons: { + icon: "/icons/icon.svg", + apple: "/icons/icon.svg", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: "#000000", }; export default function RootLayout({ @@ -13,6 +31,10 @@ export default function RootLayout({ }>) { return ( + + + + {children} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 2788d01..0c532ab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { Article } from "@/lib/types"; +import { Article, Folder } from "@/lib/types"; import { useReaderSettings, useTTSSettings } from "@/hooks/useSettings"; import { useTTS } from "@/hooks/useTTS"; import { ArticleList } from "@/components/ArticleList"; @@ -9,17 +9,33 @@ import { Reader } from "@/components/Reader"; import { SettingsPanel } from "@/components/SettingsPanel"; import { TTSControls } from "@/components/TTSControls"; import { AddArticle } from "@/components/AddArticle"; -import { BookOpen, Star, Archive, Menu } from "lucide-react"; +import { + BookOpen, + Star, + Archive, + Menu, + Search, + FolderIcon, + Settings, + BarChart3, + X, +} from "lucide-react"; +import Link from "next/link"; -type FilterType = "all" | "favorites" | "archived"; +type FilterType = "all" | "favorites" | "archived" | "folder" | "search"; export default function Home() { const [articles, setArticles] = useState([]); + const [folders, setFolders] = useState([]); const [selectedArticle, setSelectedArticle] = useState
(null); const [filter, setFilter] = useState("all"); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [isSearching, setIsSearching] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isLoading, setIsLoading] = useState(true); + const [stats, setStats] = useState<{ streak: number; todayCount: number } | null>(null); const [readerSettings, setReaderSettings] = useReaderSettings(); const [ttsSettings, setTTSSettings] = useTTSSettings(); @@ -29,15 +45,26 @@ export default function Home() { text: selectedArticle?.textContent || "", }); - // Apply theme + // Apply theme (with auto-scheduling) useEffect(() => { - document.documentElement.setAttribute("data-theme", readerSettings.theme); - }, [readerSettings.theme]); + if (readerSettings.autoTheme) { + const hour = new Date().getHours(); + const isDaytime = hour >= 6 && hour < 18; + const theme = isDaytime ? readerSettings.dayTheme : readerSettings.nightTheme; + document.documentElement.setAttribute("data-theme", theme); + } else { + document.documentElement.setAttribute("data-theme", readerSettings.theme); + } + }, [readerSettings]); // Fetch articles const fetchArticles = useCallback(async () => { try { - const response = await fetch(`/api/articles?filter=${filter}`); + let url = `/api/articles?filter=${filter}`; + if (filter === "folder" && selectedFolderId) { + url = `/api/articles?folderId=${selectedFolderId}`; + } + const response = await fetch(url); if (response.ok) { const data = await response.json(); setArticles(data); @@ -47,11 +74,65 @@ export default function Home() { } finally { setIsLoading(false); } - }, [filter]); + }, [filter, selectedFolderId]); + + // Fetch folders + const fetchFolders = useCallback(async () => { + try { + const response = await fetch("/api/folders"); + if (response.ok) { + setFolders(await response.json()); + } + } catch (error) { + console.error("Failed to fetch folders:", error); + } + }, []); + + // Fetch stats + const fetchStats = useCallback(async () => { + try { + const response = await fetch("/api/stats?days=7"); + if (response.ok) { + const data = await response.json(); + const today = new Date().toISOString().split("T")[0]; + const todayStats = data.daily.find((d: { date: string }) => d.date === today); + setStats({ + streak: data.streak, + todayCount: todayStats?.articlesRead || 0, + }); + } + } catch (error) { + console.error("Failed to fetch stats:", error); + } + }, []); useEffect(() => { fetchArticles(); - }, [fetchArticles]); + fetchFolders(); + fetchStats(); + }, [fetchArticles, fetchFolders, fetchStats]); + + // Search + const handleSearch = async () => { + if (!searchQuery.trim()) { + setFilter("all"); + return; + } + + setIsSearching(true); + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`); + if (response.ok) { + const data = await response.json(); + setArticles(data); + setFilter("search"); + } + } catch (error) { + console.error("Search failed:", error); + } finally { + setIsSearching(false); + } + }; // Add article const handleAddArticle = async (url: string) => { @@ -67,6 +148,7 @@ export default function Home() { } await fetchArticles(); + fetchStats(); }; // Toggle favorite @@ -95,6 +177,7 @@ export default function Home() { }); await fetchArticles(); + fetchStats(); if (selectedArticle?.id === id) { setSelectedArticle(null); @@ -165,8 +248,15 @@ export default function Home() { ReadLater + {stats && stats.streak > 0 && ( +

+ 🔥 {stats.streak} day streak +

+ )} -