mirror of
https://github.com/Tony0410/readlater.git
synced 2026-05-24 22:01:41 +08:00
v2.0: Major feature update
New Features: - API key authentication for external access - Apple Shortcuts integration endpoint (/api/v1/add) - Full-text search across all articles - Folders for organizing articles - Highlights and notes on articles - Reading stats with streaks - Reading goals (daily/weekly/monthly) - Import from Pocket/Instapaper - RSS feed output - PWA support for mobile - Auto theme scheduling (day/night) - Settings page with all configuration API Endpoints: - /api/v1/add - Add articles via API key - /api/keys - Manage API keys - /api/search - Full-text search - /api/folders - Folder management - /api/highlights - Highlights/notes - /api/stats - Reading statistics - /api/goals - Reading goals - /api/import - Pocket/Instapaper import - /api/rss - RSS feed Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user