mirror of
https://github.com/Tony0410/readlater.git
synced 2026-05-24 13:52:03 +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,
|
"when": 1768632959441,
|
||||||
"tag": "0000_black_lucky_pierre",
|
"tag": "0000_black_lucky_pierre",
|
||||||
"breakpoints": true
|
"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";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "ReadLater - Save and Read Articles",
|
title: "ReadLater - Save and Read Articles",
|
||||||
description: "Self-hosted read-it-later app with text-to-speech",
|
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({
|
export default function RootLayout({
|
||||||
@@ -13,6 +31,10 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="dark">
|
<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>
|
<body className="antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
205
src/app/page.tsx
205
src/app/page.tsx
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Article } from "@/lib/types";
|
import { Article, Folder } from "@/lib/types";
|
||||||
import { useReaderSettings, useTTSSettings } from "@/hooks/useSettings";
|
import { useReaderSettings, useTTSSettings } from "@/hooks/useSettings";
|
||||||
import { useTTS } from "@/hooks/useTTS";
|
import { useTTS } from "@/hooks/useTTS";
|
||||||
import { ArticleList } from "@/components/ArticleList";
|
import { ArticleList } from "@/components/ArticleList";
|
||||||
@@ -9,17 +9,33 @@ import { Reader } from "@/components/Reader";
|
|||||||
import { SettingsPanel } from "@/components/SettingsPanel";
|
import { SettingsPanel } from "@/components/SettingsPanel";
|
||||||
import { TTSControls } from "@/components/TTSControls";
|
import { TTSControls } from "@/components/TTSControls";
|
||||||
import { AddArticle } from "@/components/AddArticle";
|
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() {
|
export default function Home() {
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||||
const [filter, setFilter] = useState<FilterType>("all");
|
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 [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [stats, setStats] = useState<{ streak: number; todayCount: number } | null>(null);
|
||||||
|
|
||||||
const [readerSettings, setReaderSettings] = useReaderSettings();
|
const [readerSettings, setReaderSettings] = useReaderSettings();
|
||||||
const [ttsSettings, setTTSSettings] = useTTSSettings();
|
const [ttsSettings, setTTSSettings] = useTTSSettings();
|
||||||
@@ -29,15 +45,26 @@ export default function Home() {
|
|||||||
text: selectedArticle?.textContent || "",
|
text: selectedArticle?.textContent || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply theme
|
// Apply theme (with auto-scheduling)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
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);
|
document.documentElement.setAttribute("data-theme", readerSettings.theme);
|
||||||
}, [readerSettings.theme]);
|
}
|
||||||
|
}, [readerSettings]);
|
||||||
|
|
||||||
// Fetch articles
|
// Fetch articles
|
||||||
const fetchArticles = useCallback(async () => {
|
const fetchArticles = useCallback(async () => {
|
||||||
try {
|
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) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setArticles(data);
|
setArticles(data);
|
||||||
@@ -47,11 +74,65 @@ export default function Home() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchArticles();
|
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
|
// Add article
|
||||||
const handleAddArticle = async (url: string) => {
|
const handleAddArticle = async (url: string) => {
|
||||||
@@ -67,6 +148,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fetchArticles();
|
await fetchArticles();
|
||||||
|
fetchStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle favorite
|
// Toggle favorite
|
||||||
@@ -95,6 +177,7 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await fetchArticles();
|
await fetchArticles();
|
||||||
|
fetchStats();
|
||||||
|
|
||||||
if (selectedArticle?.id === id) {
|
if (selectedArticle?.id === id) {
|
||||||
setSelectedArticle(null);
|
setSelectedArticle(null);
|
||||||
@@ -165,8 +248,15 @@ export default function Home() {
|
|||||||
<BookOpen className="w-6 h-6" />
|
<BookOpen className="w-6 h-6" />
|
||||||
ReadLater
|
ReadLater
|
||||||
</h1>
|
</h1>
|
||||||
|
{stats && stats.streak > 0 && (
|
||||||
|
<p className="text-xs text-[var(--muted)] mt-1">
|
||||||
|
🔥 {stats.streak} day streak
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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: "all", label: "All Articles", icon: BookOpen },
|
||||||
{ value: "favorites", label: "Favorites", icon: Star },
|
{ value: "favorites", label: "Favorites", icon: Star },
|
||||||
@@ -174,9 +264,13 @@ export default function Home() {
|
|||||||
].map(({ value, label, icon: Icon }) => (
|
].map(({ value, label, icon: Icon }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
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 ${
|
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"
|
? "bg-[var(--accent)] text-white"
|
||||||
: "text-[var(--foreground)] hover:bg-[var(--surface)]"
|
: "text-[var(--foreground)] hover:bg-[var(--surface)]"
|
||||||
}`}
|
}`}
|
||||||
@@ -185,14 +279,46 @@ export default function Home() {
|
|||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
|
||||||
<div className="p-4 border-t border-[var(--border)]">
|
{/* 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
|
<button
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
key={folder.id}
|
||||||
className="w-full px-4 py-2 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors text-left"
|
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)]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Settings
|
<FolderIcon
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ color: selectedFolderId === folder.id ? "white" : folder.color }}
|
||||||
|
/>
|
||||||
|
{folder.name}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<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
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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)]">
|
<header className="flex items-center gap-4 px-4 py-3 border-b border-[var(--border)]">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
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" />
|
<Menu className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{filter === "all"
|
{/* Search */}
|
||||||
? "All Articles"
|
<div className="flex-1 flex items-center gap-2 max-w-md">
|
||||||
: filter === "favorites"
|
<div className="flex-1 relative">
|
||||||
? "Favorites"
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted)]" />
|
||||||
: "Archived"}
|
<input
|
||||||
</h2>
|
type="text"
|
||||||
<span className="text-[var(--muted)]">({articles.length})</span>
|
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>
|
</header>
|
||||||
|
|
||||||
<AddArticle onAdd={handleAddArticle} />
|
<AddArticle onAdd={handleAddArticle} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading || isSearching ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ArticleList
|
<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", {
|
export const articles = sqliteTable("articles", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
url: text("url").notNull(),
|
url: text("url").notNull(),
|
||||||
@@ -12,13 +33,83 @@ export const articles = sqliteTable("articles", {
|
|||||||
leadImage: text("lead_image"),
|
leadImage: text("lead_image"),
|
||||||
wordCount: integer("word_count").default(0),
|
wordCount: integer("word_count").default(0),
|
||||||
readingProgress: integer("reading_progress").default(0), // 0-100
|
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),
|
isFavorite: integer("is_favorite", { mode: "boolean" }).default(false),
|
||||||
isArchived: integer("is_archived", { 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
|
tags: text("tags").default("[]"), // JSON array of tags
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
|
||||||
readAt: integer("read_at", { mode: "timestamp" }),
|
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 Article = typeof articles.$inferSelect;
|
||||||
export type NewArticle = typeof articles.$inferInsert;
|
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;
|
leadImage: string | null;
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
readingProgress: number;
|
readingProgress: number;
|
||||||
|
readingTimeSeconds: number;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
|
folderId: string | null;
|
||||||
tags: string;
|
tags: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
readAt: string | null;
|
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 {
|
export interface ReaderSettings {
|
||||||
@@ -24,6 +48,9 @@ export interface ReaderSettings {
|
|||||||
lineHeight: number; // 1.4-2.2
|
lineHeight: number; // 1.4-2.2
|
||||||
maxWidth: number; // 500-900
|
maxWidth: number; // 500-900
|
||||||
theme: "dark" | "light" | "sepia";
|
theme: "dark" | "light" | "sepia";
|
||||||
|
autoTheme: boolean;
|
||||||
|
dayTheme: "dark" | "light" | "sepia";
|
||||||
|
nightTheme: "dark" | "light" | "sepia";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TTSSettings {
|
export interface TTSSettings {
|
||||||
@@ -33,12 +60,32 @@ export interface TTSSettings {
|
|||||||
kokoroUrl: string;
|
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 = {
|
export const defaultReaderSettings: ReaderSettings = {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontFamily: "serif",
|
fontFamily: "serif",
|
||||||
lineHeight: 1.8,
|
lineHeight: 1.8,
|
||||||
maxWidth: 700,
|
maxWidth: 700,
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
|
autoTheme: false,
|
||||||
|
dayTheme: "light",
|
||||||
|
nightTheme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultTTSSettings: TTSSettings = {
|
export const defaultTTSSettings: TTSSettings = {
|
||||||
|
|||||||
Reference in New Issue
Block a user