Compare commits

..

8 Commits

Author SHA1 Message Date
dependabot[bot]
7ea146fa6a Bump next in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 16.1.3 to 16.1.5
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.3...v16.1.5)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-27 19:55:25 +00:00
Gemini Agent
c6a400a04d Gracefully handle 403 blocked sites with minimal article
Instead of failing completely on 403/401, save a placeholder article
with the URL so users can still access via 'Open original' link.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 01:07:40 +00:00
Gemini Agent
96ece66204 Add article publish date to list and reader views
Extract publication dates from HTML meta tags when saving articles
and display them prominently in the article list and reader header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:33:16 +00:00
Gemini Agent
8151705b17 Fix JSDOM CSS parsing errors (border-width issue)
JSDOM crashes on modern CSS with variables like var(--border-width,1px).
Fix by stripping all <style> tags and inline style attributes before
parsing - Readability only needs DOM structure, not CSS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:20:03 +00:00
Gemini Agent
61e1ac4d81 Fix clipboard copy with fallback for iOS/older browsers
- Add try-catch around navigator.clipboard.writeText
- Add fallback using textarea + execCommand for older browsers
- Add final fallback using prompt() to show URL if all else fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:19:22 +00:00
Gemini Agent
1022b1ddca Add content capture bookmarklet for paywalled sites
- New "Content Capture" bookmarklet sends page HTML directly
- Works for paywalled sites (Economist, NYT, etc.) when logged in
- Works for Cloudflare-protected sites
- Added POST handler to /api/save for HTML content
- Added extractFromHtml() for processing captured content
- Improved 403 error message with bookmarklet suggestion
- Updated bookmarklet page with both options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:14:09 +00:00
Gemini Agent
464f93a6aa Add sorting and client-side caching for instant switching
- Add sort dropdown: Newest/Oldest/Title A-Z/Z-A
- Client-side cache with 30s TTL for instant section switching
- Re-sorting uses cached data for immediate response
- Cache clears on data modifications (add/archive/delete)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 13:42:49 +00:00
Gemini Agent
911b749d3c Add bulk select and archive, improve performance
- Add database indexes on isArchived, isFavorite, createdAt columns
- Optimize article list API to exclude content/textContent fields
- Add PATCH /api/articles endpoint for bulk updates
- Implement multi-select mode with Select/Deselect all
- Add bulk archive/unarchive buttons
- Rename "All Articles" to "To Read"
- Fetch full article content only when opening for reading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 13:27:13 +00:00
17 changed files with 1516 additions and 171 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `articles` ADD `published_at` integer;

View File

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

View File

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

141
package-lock.json generated
View File

@@ -7,14 +7,13 @@
"": {
"name": "readlater",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@mozilla/readability": "^0.6.0",
"better-sqlite3": "^12.6.2",
"drizzle-orm": "^0.45.1",
"jsdom": "^27.4.0",
"lucide-react": "^0.562.0",
"next": "16.1.3",
"next": "16.1.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"uuid": "^13.0.0"
@@ -2143,9 +2142,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.3.tgz",
"integrity": "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.5.tgz",
"integrity": "sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2159,9 +2158,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.3.tgz",
"integrity": "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.5.tgz",
"integrity": "sha512-eK7Wdm3Hjy/SCL7TevlH0C9chrpeOYWx2iR7guJDaz4zEQKWcS1IMVfMb9UKBFMg1XgzcPTYPIp1Vcpukkjg6Q==",
"cpu": [
"arm64"
],
@@ -2175,9 +2174,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.3.tgz",
"integrity": "sha512-aF4us2JXh0zn3hNxvL1Bx3BOuh8Lcw3p3Xnurlvca/iptrDH1BrpObwkw9WZra7L7/0qB9kjlREq3hN/4x4x+Q==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.5.tgz",
"integrity": "sha512-foQscSHD1dCuxBmGkbIr6ScAUF6pRoDZP6czajyvmXPAOFNnQUJu2Os1SGELODjKp/ULa4fulnBWoHV3XdPLfA==",
"cpu": [
"x64"
],
@@ -2191,9 +2190,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.3.tgz",
"integrity": "sha512-8VRkcpcfBtYvhGgXAF7U3MBx6+G1lACM1XCo1JyaUr4KmAkTNP8Dv2wdMq7BI+jqRBw3zQE7c57+lmp7jCFfKA==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.5.tgz",
"integrity": "sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==",
"cpu": [
"arm64"
],
@@ -2207,9 +2206,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.3.tgz",
"integrity": "sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.5.tgz",
"integrity": "sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==",
"cpu": [
"arm64"
],
@@ -2223,9 +2222,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.3.tgz",
"integrity": "sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.5.tgz",
"integrity": "sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==",
"cpu": [
"x64"
],
@@ -2239,9 +2238,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.3.tgz",
"integrity": "sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.5.tgz",
"integrity": "sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==",
"cpu": [
"x64"
],
@@ -2255,9 +2254,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.3.tgz",
"integrity": "sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.5.tgz",
"integrity": "sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==",
"cpu": [
"arm64"
],
@@ -2271,9 +2270,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.3.tgz",
"integrity": "sha512-1SZVfFT8zmMB+Oblrh5OKDvUo5mYQOkX2We6VGzpg7JUVZlqe4DYOFGKYZKTweSx1gbMixyO1jnFT4thU+nNHQ==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.5.tgz",
"integrity": "sha512-7is37HJTNQGhjPpQbkKjKEboHYQnCgpVt/4rBrrln0D9nderNxZ8ZWs8w1fAtzUx7wEyYjQ+/13myFgFj6K2Ng==",
"cpu": [
"x64"
],
@@ -2573,6 +2572,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -6704,12 +6763,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.3",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.3.tgz",
"integrity": "sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw==",
"version": "16.1.5",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.5.tgz",
"integrity": "sha512-f+wE+NSbiQgh3DSAlTaw2FwY5yGdVViAtp8TotNQj4kk4Q8Bh1sC/aL9aH+Rg1YAVn18OYXsRDT7U/079jgP7w==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.3",
"@next/env": "16.1.5",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001579",
@@ -6723,14 +6782,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.3",
"@next/swc-darwin-x64": "16.1.3",
"@next/swc-linux-arm64-gnu": "16.1.3",
"@next/swc-linux-arm64-musl": "16.1.3",
"@next/swc-linux-x64-gnu": "16.1.3",
"@next/swc-linux-x64-musl": "16.1.3",
"@next/swc-win32-arm64-msvc": "16.1.3",
"@next/swc-win32-x64-msvc": "16.1.3",
"@next/swc-darwin-arm64": "16.1.5",
"@next/swc-darwin-x64": "16.1.5",
"@next/swc-linux-arm64-gnu": "16.1.5",
"@next/swc-linux-arm64-musl": "16.1.5",
"@next/swc-linux-x64-gnu": "16.1.5",
"@next/swc-linux-x64-musl": "16.1.5",
"@next/swc-win32-arm64-msvc": "16.1.5",
"@next/swc-win32-x64-msvc": "16.1.5",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -17,7 +17,7 @@
"drizzle-orm": "^0.45.1",
"jsdom": "^27.4.0",
"lucide-react": "^0.562.0",
"next": "16.1.3",
"next": "16.1.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"uuid": "^13.0.0"

View File

@@ -2,15 +2,38 @@ import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { extractArticle } from "@/lib/utils/extract";
import { v4 as uuidv4 } from "uuid";
import { desc, eq } from "drizzle-orm";
import { desc, eq, inArray } from "drizzle-orm";
// GET /api/articles - List all articles
// GET /api/articles - List all articles (optimized - excludes content fields)
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const filter = searchParams.get("filter"); // all, favorites, archived
let query = db.select().from(schema.articles);
// Select only fields needed for list view (exclude large content/textContent)
const listFields = {
id: schema.articles.id,
url: schema.articles.url,
title: schema.articles.title,
author: schema.articles.author,
siteName: schema.articles.siteName,
excerpt: schema.articles.excerpt,
leadImage: schema.articles.leadImage,
wordCount: schema.articles.wordCount,
readingProgress: schema.articles.readingProgress,
readingTimeSeconds: schema.articles.readingTimeSeconds,
isFavorite: schema.articles.isFavorite,
isArchived: schema.articles.isArchived,
folderId: schema.articles.folderId,
tags: schema.articles.tags,
createdAt: schema.articles.createdAt,
updatedAt: schema.articles.updatedAt,
readAt: schema.articles.readAt,
finishedAt: schema.articles.finishedAt,
publishedAt: schema.articles.publishedAt,
};
let query = db.select(listFields).from(schema.articles);
if (filter === "favorites") {
query = query.where(eq(schema.articles.isFavorite, true)) as typeof query;
@@ -33,6 +56,49 @@ export async function GET(request: NextRequest) {
}
}
// PATCH /api/articles - Bulk update articles
export async function PATCH(request: NextRequest) {
try {
const body = await request.json();
const { ids, updates } = body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "Article IDs required" }, { status: 400 });
}
// Only allow certain fields to be bulk updated
const allowedUpdates: Partial<typeof schema.articles.$inferInsert> = {};
if (typeof updates.isArchived === "boolean") {
allowedUpdates.isArchived = updates.isArchived;
}
if (typeof updates.isFavorite === "boolean") {
allowedUpdates.isFavorite = updates.isFavorite;
}
if (updates.folderId !== undefined) {
allowedUpdates.folderId = updates.folderId;
}
if (Object.keys(allowedUpdates).length === 0) {
return NextResponse.json({ error: "No valid updates provided" }, { status: 400 });
}
allowedUpdates.updatedAt = new Date();
await db
.update(schema.articles)
.set(allowedUpdates)
.where(inArray(schema.articles.id, ids));
return NextResponse.json({ success: true, updated: ids.length });
} catch (error) {
console.error("Error bulk updating articles:", error);
return NextResponse.json(
{ error: "Failed to update articles" },
{ status: 500 }
);
}
}
// POST /api/articles - Save a new article
export async function POST(request: NextRequest) {
try {
@@ -72,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);

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { db, schema } from "@/lib/db";
import { extractArticle } from "@/lib/utils/extract";
import { extractArticle, extractFromHtml } from "@/lib/utils/extract";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
@@ -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);
@@ -138,3 +139,140 @@ export async function GET(request: NextRequest) {
);
}
}
// POST /api/save - Save article with HTML content from bookmarklet
export async function POST(request: NextRequest) {
const htmlResponse = (status: "success" | "error" | "exists", message: string) => {
const bgColor = status === "success" ? "#22c55e" : status === "exists" ? "#eab308" : "#ef4444";
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ReadLater</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #000;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
text-align: center;
max-width: 300px;
}
.icon {
width: 60px;
height: 60px;
border-radius: 50%;
background: ${bgColor};
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.icon svg {
width: 30px;
height: 30px;
fill: white;
}
h1 {
font-size: 18px;
margin-bottom: 10px;
}
p {
color: #888;
font-size: 14px;
line-height: 1.5;
}
.close {
margin-top: 20px;
padding: 10px 20px;
background: #333;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 14px;
}
.close:hover {
background: #444;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">
${status === "success" ? '<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>' : status === "exists" ? '<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>' : '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>'}
</div>
<h1>${status === "success" ? "Saved!" : status === "exists" ? "Already Saved" : "Error"}</h1>
<p>${message}</p>
<button class="close" onclick="window.close()">Close</button>
</div>
<script>
setTimeout(() => window.close(), 3000);
</script>
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
};
try {
// Parse form data from bookmarklet
const formData = await request.formData();
const url = formData.get("url") as string;
const html = formData.get("html") as string;
const title = formData.get("title") as string;
if (!url) {
return htmlResponse("error", "No URL provided");
}
// 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 htmlResponse("exists", `"${existing[0].title}" is already in your reading list`);
}
// Extract article from provided HTML content
const extracted = await extractFromHtml(html, url, title);
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,
publishedAt: extracted.publishedAt,
};
await db.insert(schema.articles).values(newArticle);
return htmlResponse("success", `"${extracted.title}" has been added to your reading list`);
} catch (error) {
console.error("Error saving article from HTML:", error);
return htmlResponse(
"error",
error instanceof Error ? error.message : "Failed to save article"
);
}
}

View File

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

View File

@@ -1,23 +1,34 @@
"use client";
import { useState, useEffect } from "react";
import { BookOpen, Copy, Check } from "lucide-react";
import { BookOpen, Copy, Check, Zap, Link2 } from "lucide-react";
import Link from "next/link";
export default function BookmarkletPage() {
const [baseUrl, setBaseUrl] = useState("");
const [copied, setCopied] = useState(false);
const [copiedSimple, setCopiedSimple] = useState(false);
const [copiedAdvanced, setCopiedAdvanced] = useState(false);
useEffect(() => {
setBaseUrl(window.location.origin);
}, []);
const bookmarkletCode = `javascript:(function(){var url=encodeURIComponent(window.location.href);window.open('${baseUrl}/api/save?url='+url,'_blank','width=400,height=300');})();`;
// Simple bookmarklet - just sends URL (works for most sites)
const simpleBookmarklet = `javascript:(function(){var url=encodeURIComponent(window.location.href);window.open('${baseUrl}/api/save?url='+url,'_blank','width=400,height=300');})();`;
const handleCopy = async () => {
await navigator.clipboard.writeText(bookmarkletCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
// Advanced bookmarklet - captures page content directly (works for paywalled/protected sites)
const advancedBookmarklet = `javascript:(function(){var d=document,b=d.body,t=d.title,u=location.href,h=d.documentElement.outerHTML;var f=d.createElement('form');f.method='POST';f.action='${baseUrl}/api/save';f.target='_blank';var addField=function(n,v){var i=d.createElement('input');i.type='hidden';i.name=n;i.value=v;f.appendChild(i);};addField('url',u);addField('title',t);addField('html',h);b.appendChild(f);f.submit();b.removeChild(f);})();`;
const handleCopySimple = async () => {
await navigator.clipboard.writeText(simpleBookmarklet);
setCopiedSimple(true);
setTimeout(() => setCopiedSimple(false), 2000);
};
const handleCopyAdvanced = async () => {
await navigator.clipboard.writeText(advancedBookmarklet);
setCopiedAdvanced(true);
setTimeout(() => setCopiedAdvanced(false), 2000);
};
return (
@@ -31,31 +42,76 @@ export default function BookmarkletPage() {
Back to ReadLater
</Link>
<h1 className="text-3xl font-bold mb-6">Bookmarklet</h1>
<h1 className="text-3xl font-bold mb-6">Bookmarklets</h1>
<div className="bg-[var(--surface)] rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">Quick Save Bookmarklet</h2>
<p className="text-[var(--muted)] mb-6">
Drag this button to your bookmarks bar, or right-click and &quot;Add to Bookmarks&quot;.
Then click it on any page to save the article to ReadLater.
{/* Advanced Bookmarklet - Recommended */}
<div className="bg-[var(--surface)] rounded-lg p-6 mb-6 border-2 border-[var(--accent)]">
<div className="flex items-center gap-2 mb-4">
<Zap className="w-5 h-5 text-[var(--accent)]" />
<h2 className="text-xl font-semibold">Content Capture (Recommended)</h2>
</div>
<p className="text-[var(--muted)] mb-4">
Captures the actual page content from your browser. <strong>Works with paywalled sites</strong> (Economist, NYT, etc.)
and sites with bot protection - as long as you can see the article, it can save it.
</p>
<div className="flex flex-col sm:flex-row gap-4 items-start">
<a
href={bookmarkletCode}
href={advancedBookmarklet}
onClick={(e) => e.preventDefault()}
className="inline-flex items-center gap-2 px-6 py-3 bg-[var(--accent)] text-white rounded-lg font-medium cursor-move"
title="Drag to bookmarks bar"
>
<BookOpen className="w-5 h-5" />
<Zap className="w-5 h-5" />
Save to ReadLater
</a>
<button
onClick={handleCopy}
className="inline-flex items-center gap-2 px-4 py-3 border border-[var(--border)] rounded-lg hover:bg-[var(--surface)] transition-colors"
onClick={handleCopyAdvanced}
className="inline-flex items-center gap-2 px-4 py-3 border border-[var(--border)] rounded-lg hover:bg-[var(--background)] transition-colors"
>
{copied ? (
{copiedAdvanced ? (
<>
<Check className="w-5 h-5 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="w-5 h-5" />
Copy code
</>
)}
</button>
</div>
</div>
{/* Simple Bookmarklet */}
<div className="bg-[var(--surface)] rounded-lg p-6 mb-8">
<div className="flex items-center gap-2 mb-4">
<Link2 className="w-5 h-5" />
<h2 className="text-xl font-semibold">URL Only (Lightweight)</h2>
</div>
<p className="text-[var(--muted)] mb-4">
Just sends the URL - our server fetches the content. Smaller bookmarklet, but won&apos;t work
for paywalled or bot-protected sites.
</p>
<div className="flex flex-col sm:flex-row gap-4 items-start">
<a
href={simpleBookmarklet}
onClick={(e) => e.preventDefault()}
className="inline-flex items-center gap-2 px-6 py-3 bg-[var(--muted)] text-white rounded-lg font-medium cursor-move"
title="Drag to bookmarks bar"
>
<Link2 className="w-5 h-5" />
Save URL
</a>
<button
onClick={handleCopySimple}
className="inline-flex items-center gap-2 px-4 py-3 border border-[var(--border)] rounded-lg hover:bg-[var(--background)] transition-colors"
>
{copiedSimple ? (
<>
<Check className="w-5 h-5 text-green-500" />
Copied!
@@ -71,22 +127,21 @@ export default function BookmarkletPage() {
</div>
<div className="bg-[var(--surface)] rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Manual Installation</h2>
<p className="text-[var(--muted)] mb-4">
If dragging doesn&apos;t work, create a new bookmark and paste this as the URL:
</p>
<pre className="bg-[var(--background)] p-4 rounded-lg overflow-x-auto text-sm">
<code className="text-[var(--muted)]">{bookmarkletCode}</code>
</pre>
<h2 className="text-xl font-semibold mb-4">Installation</h2>
<ol className="list-decimal list-inside space-y-2 text-[var(--muted)]">
<li><strong>Drag</strong> the button above to your bookmarks bar</li>
<li>Or <strong>right-click</strong> &quot;Add to Bookmarks&quot;</li>
<li>Or <strong>copy the code</strong> and create a bookmark manually</li>
</ol>
</div>
<div className="mt-8 text-[var(--muted)] text-sm">
<h3 className="font-semibold mb-2">How it works:</h3>
<ol className="list-decimal list-inside space-y-1">
<li>Click the bookmarklet on any article page</li>
<li>A popup will confirm the article was saved</li>
<li>The article appears in your ReadLater list</li>
</ol>
<h3 className="font-semibold mb-2">Tips:</h3>
<ul className="list-disc list-inside space-y-1">
<li>Use the <strong>Content Capture</strong> bookmarklet for paywalled sites you&apos;re subscribed to</li>
<li>Make sure you&apos;re logged in to see the full article before clicking</li>
<li>The bookmarklet sends the visible page content directly to ReadLater</li>
</ul>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Article, Folder } from "@/lib/types";
import { useReaderSettings, useTTSSettings } from "@/hooks/useSettings";
import { useTTS } from "@/hooks/useTTS";
@@ -17,12 +17,19 @@ import {
Search,
FolderIcon,
Settings,
BarChart3,
X,
CheckSquare,
ArchiveRestore,
ArrowUpDown,
} from "lucide-react";
import Link from "next/link";
type FilterType = "all" | "favorites" | "archived" | "folder" | "search";
type SortType = "newest" | "oldest" | "title-asc" | "title-desc";
// Simple client-side cache for instant section switching
const articleCache = new Map<string, { data: Article[]; timestamp: number }>();
const CACHE_TTL = 30000; // 30 seconds
export default function Home() {
const [articles, setArticles] = useState<Article[]>([]);
@@ -36,6 +43,11 @@ export default function Home() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [stats, setStats] = useState<{ streak: number; todayCount: number } | null>(null);
const [isSelectMode, setIsSelectMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
const [sortBy, setSortBy] = useState<SortType>("newest");
const [showSortMenu, setShowSortMenu] = useState(false);
const [readerSettings, setReaderSettings] = useReaderSettings();
const [ttsSettings, setTTSSettings] = useTTSSettings();
@@ -57,8 +69,37 @@ export default function Home() {
}
}, [readerSettings]);
// Fetch articles
const fetchArticles = useCallback(async () => {
// Sort articles client-side
const sortArticles = useCallback((articles: Article[], sort: SortType): Article[] => {
const sorted = [...articles];
switch (sort) {
case "newest":
return sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
case "oldest":
return sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
case "title-asc":
return sorted.sort((a, b) => a.title.localeCompare(b.title));
case "title-desc":
return sorted.sort((a, b) => b.title.localeCompare(a.title));
default:
return sorted;
}
}, []);
// Fetch articles with caching
const fetchArticles = useCallback(async (skipCache = false) => {
const cacheKey = filter === "folder" ? `folder-${selectedFolderId}` : filter;
// Check cache first for instant display
if (!skipCache) {
const cached = articleCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
setArticles(sortArticles(cached.data, sortBy));
setIsLoading(false);
return;
}
}
try {
let url = `/api/articles?filter=${filter}`;
if (filter === "folder" && selectedFolderId) {
@@ -67,14 +108,16 @@ export default function Home() {
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
setArticles(data);
// Cache the raw data
articleCache.set(cacheKey, { data, timestamp: Date.now() });
setArticles(sortArticles(data, sortBy));
}
} catch (error) {
console.error("Failed to fetch articles:", error);
} finally {
setIsLoading(false);
}
}, [filter, selectedFolderId]);
}, [filter, selectedFolderId, sortBy, sortArticles]);
// Fetch folders
const fetchFolders = useCallback(async () => {
@@ -112,6 +155,15 @@ export default function Home() {
fetchStats();
}, [fetchArticles, fetchFolders, fetchStats]);
// Re-sort when sort changes (use cached data for instant response)
useEffect(() => {
const cacheKey = filter === "folder" ? `folder-${selectedFolderId}` : filter;
const cached = articleCache.get(cacheKey);
if (cached) {
setArticles(sortArticles(cached.data, sortBy));
}
}, [sortBy, filter, selectedFolderId, sortArticles]);
// Search
const handleSearch = async () => {
if (!searchQuery.trim()) {
@@ -147,7 +199,8 @@ export default function Home() {
throw new Error(data.error || "Failed to add article");
}
await fetchArticles();
articleCache.clear();
await fetchArticles(true);
fetchStats();
};
@@ -176,7 +229,8 @@ export default function Home() {
body: JSON.stringify({ isArchived }),
});
await fetchArticles();
articleCache.clear();
await fetchArticles(true);
fetchStats();
if (selectedArticle?.id === id) {
@@ -187,13 +241,87 @@ export default function Home() {
// Delete article
const handleDelete = async (id: string) => {
await fetch(`/api/articles/${id}`, { method: "DELETE" });
await fetchArticles();
articleCache.clear();
await fetchArticles(true);
if (selectedArticle?.id === id) {
setSelectedArticle(null);
}
};
// Fetch full article (with content) when opening for reading
const handleSelectArticle = async (article: Article) => {
// If article already has content, use it directly
if (article.content && article.textContent) {
setSelectedArticle(article);
return;
}
// Otherwise fetch full article
try {
const response = await fetch(`/api/articles/${article.id}`);
if (response.ok) {
const fullArticle = await response.json();
setSelectedArticle(fullArticle);
}
} catch (error) {
console.error("Failed to fetch article:", error);
}
};
// Toggle article selection
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Select all visible articles
const handleSelectAll = () => {
if (selectedIds.size === articles.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(articles.map((a) => a.id)));
}
};
// Bulk archive/unarchive
const handleBulkArchive = async (archive: boolean) => {
if (selectedIds.size === 0) return;
setIsBulkUpdating(true);
try {
await fetch("/api/articles", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ids: Array.from(selectedIds),
updates: { isArchived: archive },
}),
});
// Clear cache to force refresh
articleCache.clear();
await fetchArticles(true);
setSelectedIds(new Set());
setIsSelectMode(false);
fetchStats();
} catch (error) {
console.error("Bulk update failed:", error);
} finally {
setIsBulkUpdating(false);
}
};
// Exit select mode
const exitSelectMode = () => {
setIsSelectMode(false);
setSelectedIds(new Set());
};
// Reading view
if (selectedArticle) {
return (
@@ -268,7 +396,7 @@ export default function Home() {
<nav className="flex-1 p-2 overflow-y-auto">
{/* Main filters */}
{[
{ value: "all", label: "All Articles", icon: BookOpen },
{ value: "all", label: "To Read", icon: BookOpen },
{ value: "favorites", label: "Favorites", icon: Star },
{ value: "archived", label: "Archived", icon: Archive },
].map(({ value, label, icon: Icon }) => (
@@ -335,45 +463,141 @@ export default function Home() {
{/* Main content */}
<main className="flex-1 flex flex-col min-w-0">
<header className="flex items-center gap-4 px-4 py-3 border-b border-[var(--border)]">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 rounded hover:bg-[var(--surface)] transition-colors"
>
<Menu className="w-5 h-5" />
</button>
{/* Select mode bar */}
{isSelectMode ? (
<header className="flex items-center gap-4 px-4 py-3 border-b border-[var(--border)] bg-[var(--accent)]/10">
<button
onClick={exitSelectMode}
className="p-2 rounded hover:bg-[var(--surface)] transition-colors"
>
<X className="w-5 h-5" />
</button>
<span className="font-medium">
{selectedIds.size} selected
</span>
<button
onClick={handleSelectAll}
className="text-sm text-[var(--accent)] hover:underline"
>
{selectedIds.size === articles.length ? "Deselect all" : "Select all"}
</button>
<div className="flex-1" />
{filter === "archived" ? (
<button
onClick={() => handleBulkArchive(false)}
disabled={selectedIds.size === 0 || isBulkUpdating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
<ArchiveRestore className="w-4 h-4" />
{isBulkUpdating ? "Moving..." : "Unarchive"}
</button>
) : (
<button
onClick={() => handleBulkArchive(true)}
disabled={selectedIds.size === 0 || isBulkUpdating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Archive className="w-4 h-4" />
{isBulkUpdating ? "Archiving..." : "Archive"}
</button>
)}
</header>
) : (
<header className="flex items-center gap-4 px-4 py-3 border-b border-[var(--border)]">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="p-2 rounded hover:bg-[var(--surface)] transition-colors"
>
<Menu className="w-5 h-5" />
</button>
{/* Search */}
<div className="flex-1 flex items-center gap-2 max-w-md">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search articles..."
className="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery("");
setFilter("all");
fetchArticles();
}}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="w-4 h-4 text-[var(--muted)]" />
</button>
{/* Search */}
<div className="flex-1 flex items-center gap-2 max-w-md">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted)]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search articles..."
className="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery("");
setFilter("all");
fetchArticles();
}}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="w-4 h-4 text-[var(--muted)]" />
</button>
)}
</div>
</div>
<span className="text-[var(--muted)] text-sm">
{articles.length} articles
</span>
{/* Sort dropdown */}
<div className="relative">
<button
onClick={() => setShowSortMenu(!showSortMenu)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
>
<ArrowUpDown className="w-4 h-4" />
<span className="hidden sm:inline">
{sortBy === "newest" && "Newest"}
{sortBy === "oldest" && "Oldest"}
{sortBy === "title-asc" && "A-Z"}
{sortBy === "title-desc" && "Z-A"}
</span>
</button>
{showSortMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowSortMenu(false)}
/>
<div className="absolute right-0 top-full mt-1 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-20 py-1 min-w-[140px]">
{[
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
{ value: "title-asc", label: "Title A-Z" },
{ value: "title-desc", label: "Title Z-A" },
].map((option) => (
<button
key={option.value}
onClick={() => {
setSortBy(option.value as SortType);
setShowSortMenu(false);
}}
className={`w-full px-4 py-2 text-left text-sm hover:bg-[var(--surface)] transition-colors ${
sortBy === option.value ? "text-[var(--accent)] font-medium" : ""
}`}
>
{option.label}
</button>
))}
</div>
</>
)}
</div>
</div>
<span className="text-[var(--muted)] text-sm">
{articles.length} articles
</span>
</header>
{articles.length > 0 && (
<button
onClick={() => setIsSelectMode(true)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--surface)] transition-colors"
>
<CheckSquare className="w-4 h-4" />
Select
</button>
)}
</header>
)}
<AddArticle onAdd={handleAddArticle} />
@@ -387,7 +611,10 @@ export default function Home() {
) : (
<ArticleList
articles={articles}
onSelect={setSelectedArticle}
isSelectMode={isSelectMode}
selectedIds={selectedIds}
onSelect={handleSelectArticle}
onToggleSelect={handleToggleSelect}
onToggleFavorite={handleToggleFavorite}
onToggleArchive={handleToggleArchive}
onDelete={handleDelete}

View File

@@ -91,9 +91,28 @@ export default function SettingsPage() {
};
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers or permission denied
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// If all else fails, show the URL in a prompt
window.prompt("Copy this URL:", text);
}
document.body.removeChild(textArea);
}
};
const handleImport = async () => {

View File

@@ -1,13 +1,16 @@
"use client";
import { Article } from "@/lib/types";
import { Star, Archive, Trash2, ExternalLink, Clock } from "lucide-react";
import { formatDistanceToNow } from "@/lib/utils/date";
import { Star, Archive, Trash2, ExternalLink, Clock, CheckSquare, Square } from "lucide-react";
import { formatDistanceToNow, formatDate } from "@/lib/utils/date";
interface ArticleListProps {
articles: Article[];
selectedId?: string;
selectedIds?: Set<string>;
isSelectMode?: boolean;
onSelect: (article: Article) => void;
onToggleSelect?: (id: string) => void;
onToggleFavorite: (id: string, isFavorite: boolean) => void;
onToggleArchive: (id: string, isArchived: boolean) => void;
onDelete: (id: string) => void;
@@ -16,7 +19,10 @@ interface ArticleListProps {
export function ArticleList({
articles,
selectedId,
selectedIds = new Set(),
isSelectMode = false,
onSelect,
onToggleSelect,
onToggleFavorite,
onToggleArchive,
onDelete,
@@ -51,46 +57,66 @@ export function ArticleList({
return (
<div className="divide-y divide-[var(--border)]">
{articles.map((article) => (
<div
key={article.id}
className={`p-4 cursor-pointer transition-colors hover:bg-[var(--surface)] ${
selectedId === article.id ? "bg-[var(--surface)]" : ""
}`}
onClick={() => onSelect(article)}
>
<div className="flex gap-4">
{article.leadImage && (
<img
src={article.leadImage}
alt=""
className="w-20 h-20 object-cover rounded flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate mb-1">{article.title}</h3>
<p className="text-sm text-[var(--muted)] mb-2">
{article.siteName}
{article.author && ` · ${article.author}`}
</p>
{article.excerpt && (
<p className="text-sm text-[var(--muted)] line-clamp-2">
{article.excerpt}
</p>
{articles.map((article) => {
const isSelected = selectedIds.has(article.id);
return (
<div
key={article.id}
className={`p-4 cursor-pointer transition-colors hover:bg-[var(--surface)] ${
selectedId === article.id ? "bg-[var(--surface)]" : ""
} ${isSelected ? "bg-[var(--accent)]/10" : ""}`}
onClick={() => {
if (isSelectMode && onToggleSelect) {
onToggleSelect(article.id);
} else {
onSelect(article);
}
}}
>
<div className="flex gap-4">
{isSelectMode && (
<div className="flex-shrink-0 flex items-start pt-1">
{isSelected ? (
<CheckSquare className="w-5 h-5 text-[var(--accent)]" />
) : (
<Square className="w-5 h-5 text-[var(--muted)]" />
)}
</div>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--muted)]">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{Math.ceil(article.wordCount / 200)} min read
</span>
<span>{formatDistanceToNow(article.createdAt)}</span>
{article.readingProgress > 0 && article.readingProgress < 100 && (
<span>{article.readingProgress}% read</span>
{article.leadImage && !isSelectMode && (
<img
src={article.leadImage}
alt=""
className="w-20 h-20 object-cover rounded flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate mb-1">{article.title}</h3>
<p className="text-sm text-[var(--muted)] mb-2">
{article.siteName}
{article.author && ` · ${article.author}`}
</p>
{!isSelectMode && article.excerpt && (
<p className="text-sm text-[var(--muted)] line-clamp-2">
{article.excerpt}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--muted)]">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{Math.ceil(article.wordCount / 200)} min read
</span>
{article.publishedAt && (
<span title="Published date">{formatDate(article.publishedAt)}</span>
)}
<span title="Added to library">{formatDistanceToNow(article.createdAt)}</span>
{article.readingProgress > 0 && article.readingProgress < 100 && (
<span>{article.readingProgress}% read</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-3">
{!isSelectMode && <div className="flex items-center gap-2 mt-3">
<button
onClick={(e) => {
e.stopPropagation();
@@ -141,9 +167,10 @@ export function ArticleList({
>
<Trash2 className="w-4 h-4" />
</button>
</div>}
</div>
</div>
))}
);
})}
</div>
);
}

View File

@@ -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({
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[var(--muted)] text-sm">
{article.siteName && <span>{article.siteName}</span>}
{article.author && <span>By {article.author}</span>}
{article.publishedAt && (
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" />
{formatDate(article.publishedAt)}
</span>
)}
<span>{Math.ceil(article.wordCount / 200)} min read</span>
</div>
</header>

View File

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

View File

@@ -19,6 +19,7 @@ export interface Article {
updatedAt: string;
readAt: string | null;
finishedAt: string | null;
publishedAt: string | null;
}
export interface Folder {

View File

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

View File

@@ -1,5 +1,28 @@
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import { JSDOM, VirtualConsole } from "jsdom";
// Create a virtual console that suppresses CSS parsing errors
// JSDOM has issues with modern CSS (variables, etc.) that don't affect Readability
function createVirtualConsole() {
const virtualConsole = new VirtualConsole();
virtualConsole.on("error", () => {
// Suppress CSS parsing errors
});
virtualConsole.on("warn", () => {
// Suppress warnings
});
return virtualConsole;
}
// Strip style tags and inline styles from HTML to prevent JSDOM CSS parsing errors
// Readability doesn't need CSS - it only needs the DOM structure
function stripStyles(html: string): string {
// Remove <style> tags and their contents
let cleaned = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// Remove style attributes (but keep the rest of the tag)
cleaned = cleaned.replace(/\s+style\s*=\s*["'][^"']*["']/gi, "");
return cleaned;
}
export interface ExtractedArticle {
title: string;
@@ -10,22 +33,55 @@ export interface ExtractedArticle {
textContent: string;
leadImage: string | null;
wordCount: number;
publishedAt: Date | null;
}
export async function extractArticle(url: string): Promise<ExtractedArticle> {
// Fetch the page
// Fetch the page with browser-like headers to avoid bot detection
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; ReadLater/1.0)",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
},
});
if (!response.ok) {
// On 403/blocked, return minimal article with just URL info
if (response.status === 403 || response.status === 401) {
const hostname = new URL(url).hostname.replace(/^www\./, "");
return {
title: `Article from ${hostname}`,
author: null,
siteName: hostname,
excerpt: "This site blocked automated access. Use 'Open original' to read, or the Content Capture bookmarklet to save the full article.",
content: `<p>This site blocked automated access. <a href="${url}" target="_blank">Open original article</a> to read.</p><p>Tip: Use the Content Capture bookmarklet from the article page to save the full content.</p>`,
textContent: "This site blocked automated access. Open original article to read.",
leadImage: null,
wordCount: 0,
publishedAt: null,
};
}
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
}
const html = await response.text();
const dom = new JSDOM(html, { url });
const cleanedHtml = stripStyles(html);
const dom = new JSDOM(cleanedHtml, {
url,
virtualConsole: createVirtualConsole(),
});
const document = dom.window.document;
// Extract using Readability
@@ -43,6 +99,34 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
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 || "";
@@ -58,5 +142,81 @@ export async function extractArticle(url: string): Promise<ExtractedArticle> {
textContent,
leadImage,
wordCount,
publishedAt,
};
}
// Extract article from provided HTML content (for bookmarklet with content capture)
export async function extractFromHtml(
html: string,
url: string,
fallbackTitle?: string
): Promise<ExtractedArticle> {
const cleanedHtml = stripStyles(html);
const dom = new JSDOM(cleanedHtml, {
url,
virtualConsole: createVirtualConsole(),
});
const document = dom.window.document;
// Extract using Readability
const reader = new Readability(document);
const article = reader.parse();
if (!article) {
throw new Error("Could not extract article content from provided HTML");
}
// Try to find lead image
let leadImage: string | null = null;
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogImage) {
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 || "";
// Calculate word count
const wordCount = textContent.split(/\s+/).filter(Boolean).length;
return {
title: article.title || fallbackTitle || "Untitled",
author: article.byline || null,
siteName: article.siteName || new URL(url).hostname,
excerpt: article.excerpt || null,
content,
textContent,
leadImage,
wordCount,
publishedAt,
};
}