mirror of
https://github.com/Tony0410/readlater.git
synced 2026-05-24 22:01:41 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
67
drizzle/0001_watery_the_santerians.sql
Normal file
67
drizzle/0001_watery_the_santerians.sql
Normal file
@@ -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;
|
||||
559
drizzle/meta/0001_snapshot.json
Normal file
559
drizzle/meta/0001_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
6
public/icons/icon.svg
Normal file
6
public/icons/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<rect width="512" height="512" rx="96" fill="#000"/>
|
||||
<path d="M128 144h256v32H128zM128 208h256v32H128zM128 272h192v32H128zM128 336h224v32H128z" fill="#fff"/>
|
||||
<circle cx="384" cy="384" r="80" fill="#3b82f6"/>
|
||||
<path d="M384 344v80M344 384h80" stroke="#fff" stroke-width="16" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
37
public/manifest.json
Normal file
37
public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
94
src/app/api/folders/route.ts
Normal file
94
src/app/api/folders/route.ts
Normal file
@@ -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<schema.Folder> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
103
src/app/api/goals/route.ts
Normal file
103
src/app/api/goals/route.ts
Normal file
@@ -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<schema.ReadingGoal> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/highlights/route.ts
Normal file
95
src/app/api/highlights/route.ts
Normal file
@@ -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<schema.Highlight> = {};
|
||||
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 });
|
||||
}
|
||||
}
|
||||
137
src/app/api/import/route.ts
Normal file
137
src/app/api/import/route.ts
Normal file
@@ -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 = /<a[^>]+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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/app/api/keys/route.ts
Normal file
49
src/app/api/keys/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/rss/route.ts
Normal file
63
src/app/api/rss/route.ts
Normal file
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>ReadLater - ${filter === "favorites" ? "Favorites" : filter === "archived" ? "Archived" : "Reading List"}</title>
|
||||
<link>${baseUrl}</link>
|
||||
<description>Your saved articles from ReadLater</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
||||
<atom:link href="${baseUrl}/api/rss?filter=${filter}" rel="self" type="application/rss+xml"/>
|
||||
${articles
|
||||
.map(
|
||||
(article) => `
|
||||
<item>
|
||||
<title><![CDATA[${article.title}]]></title>
|
||||
<link>${article.url}</link>
|
||||
<guid isPermaLink="false">${article.id}</guid>
|
||||
<pubDate>${new Date(article.createdAt || Date.now()).toUTCString()}</pubDate>
|
||||
<description><![CDATA[${article.excerpt || ""}]]></description>
|
||||
${article.author ? `<author>${article.author}</author>` : ""}
|
||||
${article.siteName ? `<source url="${article.url}">${article.siteName}</source>` : ""}
|
||||
</item>`
|
||||
)
|
||||
.join("")}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/search/route.ts
Normal file
37
src/app/api/search/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
129
src/app/api/stats/route.ts
Normal file
129
src/app/api/stats/route.ts
Normal file
@@ -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<number>`count(*)`,
|
||||
favorites: sql<number>`sum(case when is_favorite = 1 then 1 else 0 end)`,
|
||||
archived: sql<number>`sum(case when is_archived = 1 then 1 else 0 end)`,
|
||||
unread: sql<number>`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 });
|
||||
}
|
||||
}
|
||||
145
src/app/api/v1/add/route.ts
Normal file
145
src/app/api/v1/add/route.ts
Normal file
@@ -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 <key> 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);
|
||||
}
|
||||
@@ -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 (
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
207
src/app/page.tsx
207
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<Article[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(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() {
|
||||
<BookOpen className="w-6 h-6" />
|
||||
ReadLater
|
||||
</h1>
|
||||
{stats && stats.streak > 0 && (
|
||||
<p className="text-xs text-[var(--muted)] mt-1">
|
||||
🔥 {stats.streak} day streak
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex-1 p-2">
|
||||
|
||||
<nav className="flex-1 p-2 overflow-y-auto">
|
||||
{/* Main filters */}
|
||||
{[
|
||||
{ value: "all", label: "All Articles", icon: BookOpen },
|
||||
{ value: "favorites", label: "Favorites", icon: Star },
|
||||
@@ -174,9 +264,13 @@ export default function Home() {
|
||||
].map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setFilter(value as FilterType)}
|
||||
onClick={() => {
|
||||
setFilter(value as FilterType);
|
||||
setSelectedFolderId(null);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
filter === value
|
||||
filter === value && !selectedFolderId
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--foreground)] hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
@@ -185,14 +279,46 @@ export default function Home() {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Folders */}
|
||||
{folders.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border)]">
|
||||
<p className="px-4 py-2 text-xs text-[var(--muted)] uppercase tracking-wide">
|
||||
Folders
|
||||
</p>
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
onClick={() => {
|
||||
setFilter("folder");
|
||||
setSelectedFolderId(folder.id);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
selectedFolderId === folder.id
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--foreground)] hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
<FolderIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: selectedFolderId === folder.id ? "white" : folder.color }}
|
||||
/>
|
||||
{folder.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="w-full px-4 py-2 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors text-left"
|
||||
|
||||
<div className="p-2 border-t border-[var(--border)]">
|
||||
<Link
|
||||
href="/settings"
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
Settings
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -202,26 +328,51 @@ export default function Home() {
|
||||
<header className="flex items-center gap-4 px-4 py-3 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="p-2 rounded hover:bg-[var(--surface)] transition-colors lg:hidden"
|
||||
className="p-2 rounded hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{filter === "all"
|
||||
? "All Articles"
|
||||
: filter === "favorites"
|
||||
? "Favorites"
|
||||
: "Archived"}
|
||||
</h2>
|
||||
<span className="text-[var(--muted)]">({articles.length})</span>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 flex items-center gap-2 max-w-md">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Search articles..."
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setFilter("all");
|
||||
fetchArticles();
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="w-4 h-4 text-[var(--muted)]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[var(--muted)] text-sm">
|
||||
{articles.length} articles
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<AddArticle onAdd={handleAddArticle} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
{isLoading || isSearching ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-pulse text-[var(--muted)]">Loading...</div>
|
||||
<div className="animate-pulse text-[var(--muted)]">
|
||||
{isSearching ? "Searching..." : "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ArticleList
|
||||
|
||||
477
src/app/settings/page.tsx
Normal file
477
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Smartphone,
|
||||
Upload,
|
||||
Download,
|
||||
Target,
|
||||
Clock,
|
||||
Rss,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
maskedKey: string;
|
||||
lastUsed: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Goal {
|
||||
id: string;
|
||||
type: string;
|
||||
metric: string;
|
||||
target: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [goals, setGoals] = useState<Goal[]>([]);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [newKey, setNewKey] = useState<ApiKey | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setBaseUrl(window.location.origin);
|
||||
fetchApiKeys();
|
||||
fetchGoals();
|
||||
}, []);
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
const res = await fetch("/api/keys");
|
||||
if (res.ok) {
|
||||
setApiKeys(await res.json());
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGoals = async () => {
|
||||
const res = await fetch("/api/goals");
|
||||
if (res.ok) {
|
||||
setGoals(await res.json());
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
const res = await fetch("/api/keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newKeyName }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const key = await res.json();
|
||||
setNewKey(key);
|
||||
setNewKeyName("");
|
||||
fetchApiKeys();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (id: string) => {
|
||||
if (!confirm("Delete this API key?")) return;
|
||||
|
||||
await fetch(`/api/keys?id=${id}`, { method: "DELETE" });
|
||||
fetchApiKeys();
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importFile) return;
|
||||
|
||||
setImportStatus("Importing...");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
|
||||
const res = await fetch("/api/import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": importFile.type || "application/json",
|
||||
},
|
||||
body: await importFile.text(),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setImportStatus(
|
||||
`Imported ${result.imported} articles. Skipped ${result.skipped}. Failed ${result.failed}.`
|
||||
);
|
||||
} else {
|
||||
setImportStatus(`Error: ${result.error}`);
|
||||
}
|
||||
|
||||
setImportFile(null);
|
||||
};
|
||||
|
||||
const createGoal = async (type: string, metric: string, target: number) => {
|
||||
const res = await fetch("/api/goals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, metric, target }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
fetchGoals();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGoal = async (id: string) => {
|
||||
await fetch(`/api/goals?id=${id}`, { method: "DELETE" });
|
||||
fetchGoals();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<header className="sticky top-0 z-10 bg-[var(--background)] border-b border-[var(--border)]">
|
||||
<div className="max-w-3xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="p-2 rounded hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Settings</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-3xl mx-auto px-4 py-8 space-y-12">
|
||||
{/* API Keys */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Key className="w-6 h-6 text-[var(--accent)]" />
|
||||
<h2 className="text-lg font-semibold">API Keys</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--muted)] mb-4">
|
||||
Create API keys to add articles from Apple Shortcuts, Raycast, or other apps.
|
||||
</p>
|
||||
|
||||
{/* New key display */}
|
||||
{newKey && (
|
||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-400 font-medium mb-2">New API Key Created!</p>
|
||||
<p className="text-sm text-[var(--muted)] mb-3">
|
||||
Copy this key now - you won't be able to see it again.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 bg-[var(--surface)] px-3 py-2 rounded text-sm font-mono">
|
||||
{newKey.key}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(newKey.key)}
|
||||
className="px-3 py-2 bg-[var(--surface)] rounded hover:bg-[var(--border)] transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-3 text-sm text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new key */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="Key name (e.g., iPhone Shortcut)"
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
|
||||
/>
|
||||
<button
|
||||
onClick={createApiKey}
|
||||
disabled={!newKeyName.trim()}
|
||||
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Key list */}
|
||||
<div className="space-y-2">
|
||||
{apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-4 bg-[var(--surface)] rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{key.name}</p>
|
||||
<p className="text-sm text-[var(--muted)] font-mono">{key.maskedKey}</p>
|
||||
{key.lastUsed && (
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
Last used: {new Date(key.lastUsed).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
className="p-2 text-red-500 hover:bg-red-500/10 rounded transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{apiKeys.length === 0 && (
|
||||
<p className="text-[var(--muted)] text-center py-4">No API keys yet</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Apple Shortcuts */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Smartphone className="w-6 h-6 text-[var(--accent)]" />
|
||||
<h2 className="text-lg font-semibold">Apple Shortcuts</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--surface)] rounded-lg p-6">
|
||||
<p className="text-[var(--muted)] mb-4">
|
||||
Add articles from your iPhone, iPad, or Mac using Shortcuts:
|
||||
</p>
|
||||
|
||||
<ol className="list-decimal list-inside space-y-3 text-sm">
|
||||
<li>Create an API key above</li>
|
||||
<li>Open the Shortcuts app</li>
|
||||
<li>Create a new shortcut with these actions:</li>
|
||||
</ol>
|
||||
|
||||
<div className="mt-4 bg-[var(--background)] rounded-lg p-4 text-sm font-mono">
|
||||
<p className="text-[var(--muted)]"># Get URL from Share Sheet or Clipboard</p>
|
||||
<p className="mt-2">Get URLs from Input</p>
|
||||
<p className="text-[var(--muted)] mt-3"># Send to ReadLater</p>
|
||||
<p className="mt-2">URL: <span className="text-[var(--accent)]">{baseUrl}/api/v1/add</span></p>
|
||||
<p>Method: POST</p>
|
||||
<p>Headers:</p>
|
||||
<p className="pl-4">Authorization: Bearer YOUR_API_KEY</p>
|
||||
<p className="pl-4">Content-Type: application/json</p>
|
||||
<p>Body: {`{"url": "[URL from above]"}`}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--muted)] mt-4">
|
||||
Add the shortcut to your Share Sheet to save articles from Safari and other apps.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Import/Export */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Upload className="w-6 h-6 text-[var(--accent)]" />
|
||||
<h2 className="text-lg font-semibold">Import & Export</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Import */}
|
||||
<div className="bg-[var(--surface)] rounded-lg p-6">
|
||||
<h3 className="font-medium mb-2">Import</h3>
|
||||
<p className="text-sm text-[var(--muted)] mb-4">
|
||||
Import from Pocket, Instapaper, or CSV
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.html,.csv"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="block w-full text-sm mb-3"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!importFile}
|
||||
className="w-full px-4 py-2 bg-[var(--accent)] text-white rounded-lg disabled:opacity-50"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
|
||||
{importStatus && (
|
||||
<p className="text-sm mt-3 text-[var(--muted)]">{importStatus}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="bg-[var(--surface)] rounded-lg p-6">
|
||||
<h3 className="font-medium mb-2">Export</h3>
|
||||
<p className="text-sm text-[var(--muted)] mb-4">
|
||||
Download your reading list
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/api/articles?export=json"
|
||||
download="readlater-export.json"
|
||||
className="block w-full px-4 py-2 bg-[var(--border)] text-center rounded-lg hover:bg-[var(--muted)]/20 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4 inline mr-2" />
|
||||
Export JSON
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* RSS Feeds */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Rss className="w-6 h-6 text-[var(--accent)]" />
|
||||
<h2 className="text-lg font-semibold">RSS Feeds</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--surface)] rounded-lg p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>All Articles</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`${baseUrl}/api/rss`)}
|
||||
className="text-sm text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Favorites</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`${baseUrl}/api/rss?filter=favorites`)}
|
||||
className="text-sm text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Archived</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`${baseUrl}/api/rss?filter=archived`)}
|
||||
className="text-sm text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reading Goals */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Target className="w-6 h-6 text-[var(--accent)]" />
|
||||
<h2 className="text-lg font-semibold">Reading Goals</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{goals.map((goal) => (
|
||||
<div
|
||||
key={goal.id}
|
||||
className="flex items-center justify-between p-4 bg-[var(--surface)] rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium capitalize">
|
||||
{goal.type} Goal: {goal.target} {goal.metric}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{goal.isActive ? "Active" : "Paused"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteGoal(goal.id)}
|
||||
className="p-2 text-red-500 hover:bg-red-500/10 rounded"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="bg-[var(--surface)] rounded-lg p-4">
|
||||
<p className="text-sm font-medium mb-3">Quick Add Goal:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => createGoal("daily", "articles", 1)}
|
||||
className="px-3 py-1 text-sm bg-[var(--border)] rounded hover:bg-[var(--muted)]/20"
|
||||
>
|
||||
1 article/day
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createGoal("daily", "time", 30)}
|
||||
className="px-3 py-1 text-sm bg-[var(--border)] rounded hover:bg-[var(--muted)]/20"
|
||||
>
|
||||
30 min/day
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createGoal("weekly", "articles", 7)}
|
||||
className="px-3 py-1 text-sm bg-[var(--border)] rounded hover:bg-[var(--muted)]/20"
|
||||
>
|
||||
7 articles/week
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Theme Schedule */}
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Sun className="w-6 h-6 text-[var(--accent)]" />
|
||||
<h2 className="text-lg font-semibold">Theme Schedule</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--surface)] rounded-lg p-6">
|
||||
<p className="text-[var(--muted)] mb-4">
|
||||
Automatically switch themes based on time of day.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
<span>Day Theme (6am - 6pm)</span>
|
||||
</div>
|
||||
<select className="bg-[var(--background)] border border-[var(--border)] rounded px-3 py-1">
|
||||
<option value="light">Light</option>
|
||||
<option value="sepia">Sepia</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Moon className="w-5 h-5 text-blue-400" />
|
||||
<span>Night Theme (6pm - 6am)</span>
|
||||
</div>
|
||||
<select className="bg-[var(--background)] border border-[var(--border)] rounded px-3 py-1">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="sepia">Sepia</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--muted)] mt-4">
|
||||
Theme scheduling uses your device's local time.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/lib/auth.ts
Normal file
85
src/lib/auth.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { db, schema } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Generate a secure API key
|
||||
export function generateApiKey(): string {
|
||||
return `rl_${crypto.randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
// Validate API key and return the key record if valid
|
||||
export async function validateApiKey(key: string): Promise<schema.ApiKey | null> {
|
||||
if (!key || !key.startsWith("rl_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.apiKeys)
|
||||
.where(eq(schema.apiKeys.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
await db
|
||||
.update(schema.apiKeys)
|
||||
.set({ lastUsed: new Date() })
|
||||
.where(eq(schema.apiKeys.id, result[0].id));
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// Create a new API key
|
||||
export async function createApiKey(name: string): Promise<schema.ApiKey> {
|
||||
const id = uuidv4();
|
||||
const key = generateApiKey();
|
||||
|
||||
await db.insert(schema.apiKeys).values({
|
||||
id,
|
||||
name,
|
||||
key,
|
||||
});
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.apiKeys)
|
||||
.where(eq(schema.apiKeys.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
// List all API keys (with keys partially masked)
|
||||
export async function listApiKeys(): Promise<Array<schema.ApiKey & { maskedKey: string }>> {
|
||||
const keys = await db.select().from(schema.apiKeys);
|
||||
return keys.map((k) => ({
|
||||
...k,
|
||||
maskedKey: `${k.key.slice(0, 7)}...${k.key.slice(-4)}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Delete an API key
|
||||
export async function deleteApiKey(id: string): Promise<void> {
|
||||
await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id));
|
||||
}
|
||||
|
||||
// Extract API key from request headers
|
||||
export function extractApiKey(headers: Headers): string | null {
|
||||
// Check Authorization header (Bearer token)
|
||||
const auth = headers.get("Authorization");
|
||||
if (auth?.startsWith("Bearer ")) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
|
||||
// Check X-API-Key header
|
||||
const apiKey = headers.get("X-API-Key");
|
||||
if (apiKey) {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// API Keys for authentication
|
||||
export const apiKeys = sqliteTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(), // e.g., "iPhone Shortcut", "Mac Safari"
|
||||
key: text("key").notNull().unique(),
|
||||
lastUsed: integer("last_used", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Folders for organizing articles
|
||||
export const folders = sqliteTable("folders", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
color: text("color").default("#3b82f6"), // Accent color
|
||||
icon: text("icon").default("folder"), // Lucide icon name
|
||||
parentId: text("parent_id"), // For nested folders
|
||||
sortOrder: integer("sort_order").default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Articles
|
||||
export const articles = sqliteTable("articles", {
|
||||
id: text("id").primaryKey(),
|
||||
url: text("url").notNull(),
|
||||
@@ -12,13 +33,83 @@ export const articles = sqliteTable("articles", {
|
||||
leadImage: text("lead_image"),
|
||||
wordCount: integer("word_count").default(0),
|
||||
readingProgress: integer("reading_progress").default(0), // 0-100
|
||||
readingTimeSeconds: integer("reading_time_seconds").default(0), // Total time spent reading
|
||||
isFavorite: integer("is_favorite", { mode: "boolean" }).default(false),
|
||||
isArchived: integer("is_archived", { mode: "boolean" }).default(false),
|
||||
folderId: text("folder_id"), // Link to folder
|
||||
tags: text("tags").default("[]"), // JSON array of tags
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
readAt: integer("read_at", { mode: "timestamp" }),
|
||||
finishedAt: integer("finished_at", { mode: "timestamp" }), // When reading was completed
|
||||
});
|
||||
|
||||
// Highlights and notes
|
||||
export const highlights = sqliteTable("highlights", {
|
||||
id: text("id").primaryKey(),
|
||||
articleId: text("article_id").notNull(),
|
||||
text: text("text").notNull(), // Highlighted text
|
||||
note: text("note"), // Optional note
|
||||
color: text("color").default("#fbbf24"), // Highlight color
|
||||
startOffset: integer("start_offset"), // Position in textContent
|
||||
endOffset: integer("end_offset"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Daily reading stats
|
||||
export const readingStats = sqliteTable("reading_stats", {
|
||||
id: text("id").primaryKey(),
|
||||
date: text("date").notNull().unique(), // YYYY-MM-DD format
|
||||
articlesRead: integer("articles_read").default(0),
|
||||
articlesAdded: integer("articles_added").default(0),
|
||||
wordsRead: integer("words_read").default(0),
|
||||
timeSpentSeconds: integer("time_spent_seconds").default(0),
|
||||
streak: integer("streak").default(0), // Consecutive days
|
||||
});
|
||||
|
||||
// Reading goals
|
||||
export const readingGoals = sqliteTable("reading_goals", {
|
||||
id: text("id").primaryKey(),
|
||||
type: text("type").notNull(), // "daily", "weekly", "monthly"
|
||||
metric: text("metric").notNull(), // "articles", "words", "time"
|
||||
target: integer("target").notNull(),
|
||||
isActive: integer("is_active", { mode: "boolean" }).default(true),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// App settings
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(), // JSON encoded value
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Email inbox config (for email-to-save)
|
||||
export const emailConfig = sqliteTable("email_config", {
|
||||
id: text("id").primaryKey(),
|
||||
inboxEmail: text("inbox_email").unique(), // e.g., save-abc123@readlater.example.com
|
||||
isActive: integer("is_active", { mode: "boolean" }).default(true),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Types
|
||||
export type ApiKey = typeof apiKeys.$inferSelect;
|
||||
export type NewApiKey = typeof apiKeys.$inferInsert;
|
||||
|
||||
export type Folder = typeof folders.$inferSelect;
|
||||
export type NewFolder = typeof folders.$inferInsert;
|
||||
|
||||
export type Article = typeof articles.$inferSelect;
|
||||
export type NewArticle = typeof articles.$inferInsert;
|
||||
|
||||
export type Highlight = typeof highlights.$inferSelect;
|
||||
export type NewHighlight = typeof highlights.$inferInsert;
|
||||
|
||||
export type ReadingStats = typeof readingStats.$inferSelect;
|
||||
export type NewReadingStats = typeof readingStats.$inferInsert;
|
||||
|
||||
export type ReadingGoal = typeof readingGoals.$inferSelect;
|
||||
export type NewReadingGoal = typeof readingGoals.$inferInsert;
|
||||
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
export type NewSetting = typeof settings.$inferInsert;
|
||||
|
||||
@@ -10,12 +10,36 @@ export interface Article {
|
||||
leadImage: string | null;
|
||||
wordCount: number;
|
||||
readingProgress: number;
|
||||
readingTimeSeconds: number;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
folderId: string | null;
|
||||
tags: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
readAt: string | null;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
id: string;
|
||||
articleId: string;
|
||||
text: string;
|
||||
note: string | null;
|
||||
color: string;
|
||||
startOffset: number | null;
|
||||
endOffset: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReaderSettings {
|
||||
@@ -24,6 +48,9 @@ export interface ReaderSettings {
|
||||
lineHeight: number; // 1.4-2.2
|
||||
maxWidth: number; // 500-900
|
||||
theme: "dark" | "light" | "sepia";
|
||||
autoTheme: boolean;
|
||||
dayTheme: "dark" | "light" | "sepia";
|
||||
nightTheme: "dark" | "light" | "sepia";
|
||||
}
|
||||
|
||||
export interface TTSSettings {
|
||||
@@ -33,12 +60,32 @@ export interface TTSSettings {
|
||||
kokoroUrl: string;
|
||||
}
|
||||
|
||||
export interface ReadingStats {
|
||||
date: string;
|
||||
articlesRead: number;
|
||||
articlesAdded: number;
|
||||
wordsRead: number;
|
||||
timeSpentSeconds: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
export interface ReadingGoal {
|
||||
id: string;
|
||||
type: "daily" | "weekly" | "monthly";
|
||||
metric: "articles" | "words" | "time";
|
||||
target: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const defaultReaderSettings: ReaderSettings = {
|
||||
fontSize: 18,
|
||||
fontFamily: "serif",
|
||||
lineHeight: 1.8,
|
||||
maxWidth: 700,
|
||||
theme: "dark",
|
||||
autoTheme: false,
|
||||
dayTheme: "light",
|
||||
nightTheme: "dark",
|
||||
};
|
||||
|
||||
export const defaultTTSSettings: TTSSettings = {
|
||||
|
||||
Reference in New Issue
Block a user