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:
Gemini Agent
2026-01-17 12:19:57 +00:00
parent 27963af055
commit 513576b90e
22 changed files with 2431 additions and 30 deletions

Binary file not shown.

Binary file not shown.

View 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;

View 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": {}
}
}

View File

@@ -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
View 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
View 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"
}
]
}

View 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
View 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 });
}
}

View 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
View 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
View 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
View 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 });
}
}

View 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
View 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
View 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);
}

View File

@@ -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>
); );

View File

@@ -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
View 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&apos;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&apos;s local time.
</p>
</div>
</section>
</main>
</div>
);
}

85
src/lib/auth.ts Normal file
View 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;
}

View File

@@ -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;

View File

@@ -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 = {