diff --git a/drizzle/0002_modern_white_tiger.sql b/drizzle/0002_modern_white_tiger.sql new file mode 100644 index 0000000..c0f75d1 --- /dev/null +++ b/drizzle/0002_modern_white_tiger.sql @@ -0,0 +1 @@ +ALTER TABLE `articles` ADD `published_at` integer; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..8570fd6 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,566 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2817f3e4-6ce5-4d64-80e2-fb5fa10c8fa2", + "prevId": "d3369a08-d474-468e-a003-df32d5f2c61d", + "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 + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_config": { + "name": "email_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inbox_email": { + "name": "inbox_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "email_config_inbox_email_unique": { + "name": "email_config_inbox_email_unique", + "columns": [ + "inbox_email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "folders": { + "name": "folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#3b82f6'" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'folder'" + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#fbbf24'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reading_goals": { + "name": "reading_goals", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reading_stats": { + "name": "reading_stats", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articles_read": { + "name": "articles_read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "articles_added": { + "name": "articles_added", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "words_read": { + "name": "words_read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "streak": { + "name": "streak", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "reading_stats_date_unique": { + "name": "reading_stats_date_unique", + "columns": [ + "date" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d48004a..19e7b1d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1768638242044, "tag": "0001_watery_the_santerians", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1769236358306, + "tag": "0002_modern_white_tiger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/articles/route.ts b/src/app/api/articles/route.ts index 492f50e..0dd2a8b 100644 --- a/src/app/api/articles/route.ts +++ b/src/app/api/articles/route.ts @@ -30,6 +30,7 @@ export async function GET(request: NextRequest) { updatedAt: schema.articles.updatedAt, readAt: schema.articles.readAt, finishedAt: schema.articles.finishedAt, + publishedAt: schema.articles.publishedAt, }; let query = db.select(listFields).from(schema.articles); @@ -137,6 +138,7 @@ export async function POST(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, }; await db.insert(schema.articles).values(newArticle); diff --git a/src/app/api/save/route.ts b/src/app/api/save/route.ts index 374693a..b6bbabe 100644 --- a/src/app/api/save/route.ts +++ b/src/app/api/save/route.ts @@ -125,6 +125,7 @@ export async function GET(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, }; await db.insert(schema.articles).values(newArticle); @@ -261,6 +262,7 @@ export async function POST(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, }; await db.insert(schema.articles).values(newArticle); diff --git a/src/app/api/v1/add/route.ts b/src/app/api/v1/add/route.ts index ea17a9e..f5aaa0c 100644 --- a/src/app/api/v1/add/route.ts +++ b/src/app/api/v1/add/route.ts @@ -72,6 +72,7 @@ export async function POST(request: NextRequest) { textContent: extracted.textContent, leadImage: extracted.leadImage, wordCount: extracted.wordCount, + publishedAt: extracted.publishedAt, tags: tags ? JSON.stringify(tags) : "[]", folderId: folderId || null, }; diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index 66cae2a..b32cb58 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -2,7 +2,7 @@ import { Article } from "@/lib/types"; import { Star, Archive, Trash2, ExternalLink, Clock, CheckSquare, Square } from "lucide-react"; -import { formatDistanceToNow } from "@/lib/utils/date"; +import { formatDistanceToNow, formatDate } from "@/lib/utils/date"; interface ArticleListProps { articles: Article[]; @@ -106,7 +106,10 @@ export function ArticleList({ {Math.ceil(article.wordCount / 200)} min read - {formatDistanceToNow(article.createdAt)} + {article.publishedAt && ( + {formatDate(article.publishedAt)} + )} + {formatDistanceToNow(article.createdAt)} {article.readingProgress > 0 && article.readingProgress < 100 && ( {article.readingProgress}% read )} diff --git a/src/components/Reader.tsx b/src/components/Reader.tsx index 8172287..e51a243 100644 --- a/src/components/Reader.tsx +++ b/src/components/Reader.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { Article, ReaderSettings } from "@/lib/types"; -import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink } from "lucide-react"; +import { ArrowLeft, Star, Archive, Trash2, Settings, ExternalLink, Calendar } from "lucide-react"; +import { formatDate } from "@/lib/utils/date"; interface ReaderProps { article: Article; @@ -141,6 +142,12 @@ export function Reader({
{article.siteName && {article.siteName}} {article.author && By {article.author}} + {article.publishedAt && ( + + + {formatDate(article.publishedAt)} + + )} {Math.ceil(article.wordCount / 200)} min read
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index ce41e3c..56814df 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -42,6 +42,7 @@ export const articles = sqliteTable("articles", { updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(() => new Date()), readAt: integer("read_at", { mode: "timestamp" }), finishedAt: integer("finished_at", { mode: "timestamp" }), // When reading was completed + publishedAt: integer("published_at", { mode: "timestamp" }), // Original article publish date }); // Highlights and notes diff --git a/src/lib/types.ts b/src/lib/types.ts index b0b982a..e24f880 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -19,6 +19,7 @@ export interface Article { updatedAt: string; readAt: string | null; finishedAt: string | null; + publishedAt: string | null; } export interface Folder { diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts index 4a10348..be35e2e 100644 --- a/src/lib/utils/date.ts +++ b/src/lib/utils/date.ts @@ -1,3 +1,12 @@ +export function formatDate(date: string | Date): string { + const d = new Date(date); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + export function formatDistanceToNow(date: string | Date): string { const d = new Date(date); const now = new Date(); diff --git a/src/lib/utils/extract.ts b/src/lib/utils/extract.ts index 2f59e7f..d6e3ec3 100644 --- a/src/lib/utils/extract.ts +++ b/src/lib/utils/extract.ts @@ -33,6 +33,7 @@ export interface ExtractedArticle { textContent: string; leadImage: string | null; wordCount: number; + publishedAt: Date | null; } export async function extractArticle(url: string): Promise { @@ -86,6 +87,34 @@ export async function extractArticle(url: string): Promise { leadImage = ogImage.getAttribute("content"); } + // Try to find publish date from various meta tags + let publishedAt: Date | null = null; + const dateSelectors = [ + 'meta[property="article:published_time"]', + 'meta[name="article:published_time"]', + 'meta[property="og:published_time"]', + 'meta[name="pubdate"]', + 'meta[name="publishdate"]', + 'meta[name="date"]', + 'meta[itemprop="datePublished"]', + 'time[datetime]', + 'time[pubdate]', + ]; + + for (const selector of dateSelectors) { + const el = document.querySelector(selector); + if (el) { + const dateStr = el.getAttribute("content") || el.getAttribute("datetime"); + if (dateStr) { + const parsed = new Date(dateStr); + if (!isNaN(parsed.getTime())) { + publishedAt = parsed; + break; + } + } + } + } + const textContent = article.textContent || ""; const content = article.content || ""; @@ -101,6 +130,7 @@ export async function extractArticle(url: string): Promise { textContent, leadImage, wordCount, + publishedAt, }; } @@ -132,6 +162,34 @@ export async function extractFromHtml( leadImage = ogImage.getAttribute("content"); } + // Try to find publish date from various meta tags + let publishedAt: Date | null = null; + const dateSelectors = [ + 'meta[property="article:published_time"]', + 'meta[name="article:published_time"]', + 'meta[property="og:published_time"]', + 'meta[name="pubdate"]', + 'meta[name="publishdate"]', + 'meta[name="date"]', + 'meta[itemprop="datePublished"]', + 'time[datetime]', + 'time[pubdate]', + ]; + + for (const selector of dateSelectors) { + const el = document.querySelector(selector); + if (el) { + const dateStr = el.getAttribute("content") || el.getAttribute("datetime"); + if (dateStr) { + const parsed = new Date(dateStr); + if (!isNaN(parsed.getTime())) { + publishedAt = parsed; + break; + } + } + } + } + const textContent = article.textContent || ""; const content = article.content || ""; @@ -147,5 +205,6 @@ export async function extractFromHtml( textContent, leadImage, wordCount, + publishedAt, }; }