diff --git a/docs/plans/2026-02-27-read-it-later-plan.md b/docs/plans/2026-02-27-read-it-later-plan.md new file mode 100644 index 0000000..4e1762c --- /dev/null +++ b/docs/plans/2026-02-27-read-it-later-plan.md @@ -0,0 +1,1386 @@ +# Read It Later — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a standalone Indiekit plugin (`@rmdes/indiekit-endpoint-readlater`) that provides a private "read it later" bookmark list, with save buttons integrated into microsub reader, activitypub reader, and Eleventy frontend pages. + +**Architecture:** A minimal standalone plugin owns a single MongoDB collection (`readlater_items`) and exposes save/delete API endpoints plus an admin page. Other plugins and the Eleventy theme add per-item save icons that POST to the API. Detection is soft — buttons only render if the plugin is installed. + +**Tech Stack:** Express routes, MongoDB, Nunjucks templates (`@indiekit/frontend` layout), vanilla JS (microsub) and Alpine.js (activitypub/theme) for save button interactions. + +**Design Doc:** `docs/plans/2026-02-27-read-it-later-design.md` (in indiekit-endpoint-microsub repo) + +--- + +## Phase 1: Standalone Plugin + +> All Phase 1 files are created in a **new repo**: `/home/rick/code/indiekit-dev/indiekit-endpoint-readlater/` + +### Task 1: Initialize the plugin repo and package.json + +**Files:** +- Create: `package.json` +- Create: `.gitignore` + +**Step 1: Create repo directory** + +```bash +mkdir -p /home/rick/code/indiekit-dev/indiekit-endpoint-readlater +cd /home/rick/code/indiekit-dev/indiekit-endpoint-readlater +git init +``` + +**Step 2: Create package.json** + +```json +{ + "name": "@rmdes/indiekit-endpoint-readlater", + "version": "1.0.0", + "description": "Read It Later endpoint for Indiekit. Save URLs from any context for later consumption.", + "keywords": [ + "indiekit", + "indiekit-plugin", + "indieweb", + "read-later", + "bookmarks", + "reading-list" + ], + "homepage": "https://github.com/rmdes/indiekit-endpoint-readlater", + "bugs": { + "url": "https://github.com/rmdes/indiekit-endpoint-readlater/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rmdes/indiekit-endpoint-readlater.git" + }, + "author": { + "name": "Ricardo Mendes", + "url": "https://rmendes.net" + }, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js" + }, + "files": [ + "lib", + "locales", + "views", + "assets", + "index.js" + ], + "dependencies": { + "@indiekit/error": "^1.0.0-beta.25", + "@indiekit/frontend": "^1.0.0-beta.25", + "express": "^5.0.0" + }, + "peerDependencies": { + "@indiekit/indiekit": ">=1.0.0-beta.25" + }, + "publishConfig": { + "access": "public" + } +} +``` + +**Step 3: Create .gitignore** + +``` +node_modules/ +``` + +**Step 4: Commit** + +```bash +git add package.json .gitignore +git commit -m "chore: initialize plugin repo" +``` + +--- + +### Task 2: Storage layer — items.js + +**Files:** +- Create: `lib/storage/items.js` + +**Step 1: Create the storage module** + +This module handles all MongoDB operations for the `readlater_items` collection. + +```javascript +/** + * Read It Later item storage operations + * @module storage/items + */ + +import { ObjectId } from "mongodb"; + +/** + * Get the readlater_items collection + * @param {object} application - Indiekit application + * @returns {object} MongoDB collection + */ +function getCollection(application) { + return application.collections.get("readlater_items"); +} + +/** + * Save a URL for later reading + * @param {object} application - Indiekit application + * @param {object} data - Item data + * @param {string} data.url - URL to save + * @param {string} data.title - Display title + * @param {string} data.source - Source context (microsub, activitypub, blogroll, etc.) + * @returns {Promise<{item: object, created: boolean}>} Saved item and whether it was newly created + */ +export async function saveItem(application, { url, title, source }) { + const collection = getCollection(application); + + // Check for existing item with same URL + const existing = await collection.findOne({ url }); + if (existing) { + return { item: existing, created: false }; + } + + const item = { + url, + title: title || url, + source: source || "manual", + savedAt: new Date().toISOString(), + }; + + const result = await collection.insertOne(item); + item._id = result.insertedId; + return { item, created: true }; +} + +/** + * Delete a saved item + * @param {object} application - Indiekit application + * @param {object} params - Delete params + * @param {string} [params.id] - Item _id + * @param {string} [params.url] - Item URL + * @returns {Promise} Whether an item was deleted + */ +export async function deleteItem(application, { id, url }) { + const collection = getCollection(application); + + let filter; + if (id) { + filter = { _id: new ObjectId(id) }; + } else if (url) { + filter = { url }; + } else { + return false; + } + + const result = await collection.deleteOne(filter); + return result.deletedCount > 0; +} + +/** + * Get saved items with optional filtering and sorting + * @param {object} application - Indiekit application + * @param {object} [options] - Query options + * @param {string} [options.sort] - Sort direction: "asc" or "desc" (default: "desc") + * @param {string} [options.source] - Filter by source + * @param {string} [options.q] - Search query (matches title and url) + * @returns {Promise} Array of saved items + */ +export async function getItems(application, options = {}) { + const collection = getCollection(application); + + const filter = {}; + + if (options.source) { + filter.source = options.source; + } + + if (options.q) { + const escaped = options.q.replaceAll( + /[$()*+.?[\\\]^{|}]/g, + "\\$&", + ); + const regex = new RegExp(escaped, "i"); + filter.$or = [{ title: regex }, { url: regex }]; + } + + const sortDirection = options.sort === "asc" ? 1 : -1; + + return collection + .find(filter) + .sort({ savedAt: sortDirection }) + .toArray(); +} + +/** + * Check if a URL is already saved + * @param {object} application - Indiekit application + * @param {string} url - URL to check + * @returns {Promise} Whether the URL is saved + */ +export async function isSaved(application, url) { + const collection = getCollection(application); + const item = await collection.findOne({ url }); + return !!item; +} + +/** + * Get distinct source values that have saved items + * @param {object} application - Indiekit application + * @returns {Promise} Array of source strings + */ +export async function getSources(application) { + const collection = getCollection(application); + return collection.distinct("source"); +} + +/** + * Create MongoDB indexes for the collection + * @param {object} application - Indiekit application + */ +export async function createIndexes(application) { + const collection = getCollection(application); + await collection.createIndex({ url: 1 }, { unique: true }); + await collection.createIndex({ savedAt: -1 }); + await collection.createIndex({ source: 1 }); +} +``` + +**Step 2: Commit** + +```bash +git add lib/storage/items.js +git commit -m "feat: add storage layer for read-it-later items" +``` + +--- + +### Task 3: Controller — readlater.js + +**Files:** +- Create: `lib/controllers/readlater.js` + +**Step 1: Create the controller** + +Handles the admin page (GET) and API endpoints (POST save/delete). + +```javascript +/** + * Read It Later controller + * @module controllers/readlater + */ + +import { + saveItem, + deleteItem, + getItems, + getSources, +} from "../storage/items.js"; + +/** + * Admin page — list saved items with filters + */ +async function list(request, response) { + const { application } = request.app.locals; + const baseUrl = request.baseUrl; + + const sort = request.query.sort || "desc"; + const source = request.query.source || ""; + const q = request.query.q || ""; + + const items = await getItems(application, { sort, source, q }); + const sources = await getSources(application); + + response.render("readlater", { + title: "Read It Later", + items, + sources, + sort, + source, + q, + baseUrl, + breadcrumbs: [{ text: "Read It Later" }], + }); +} + +/** + * Save a URL — POST /readlater/save + * Accepts JSON or form-encoded: { url, title, source } + */ +async function save(request, response) { + const { application } = request.app.locals; + + const url = request.body.url; + if (!url) { + return response.status(400).json({ error: "URL is required" }); + } + + const title = request.body.title || url; + const source = request.body.source || "manual"; + + const { item, created } = await saveItem(application, { + url, + title, + source, + }); + + if (created) { + return response.json({ success: true, item }); + } + + return response.json({ success: true, item, alreadySaved: true }); +} + +/** + * Delete a saved item — POST /readlater/delete + * Accepts JSON or form-encoded: { id } or { url } + */ +async function remove(request, response) { + const { application } = request.app.locals; + + const id = request.body.id; + const url = request.body.url; + + if (!id && !url) { + return response.status(400).json({ error: "id or url is required" }); + } + + const deleted = await deleteItem(application, { id, url }); + + if (deleted) { + return response.json({ success: true }); + } + + return response.status(404).json({ error: "Not found" }); +} + +export const readlaterController = { list, save, remove }; +``` + +**Step 2: Commit** + +```bash +git add lib/controllers/readlater.js +git commit -m "feat: add controller for read-it-later admin and API" +``` + +--- + +### Task 4: Admin page template — readlater.njk + +**Files:** +- Create: `views/readlater.njk` +- Create: `assets/styles.css` + +**Step 1: Create the admin page template** + +Uses `@indiekit/frontend` layout (same as all Indiekit plugins). + +```nunjucks +{% extends "document.njk" %} + +{% block content %} + + +
+
+

{{ title }}

+
+ + {# Filters toolbar #} +
+
+ + +
+ +
+ + +
+ + + + + {% if source or q %} + Clear + {% endif %} +
+ + {# Items list #} + {% if items.length > 0 %} +
+ {% for item in items %} +
+
+ + {{ item.title }} + +
+ + {{ item.source }} + + {% if item.savedAt %} + + {% endif %} +
+
+ +
+ {% endfor %} +
+ {% else %} +
+

{% if q or source %}No items match your filters.{% else %}No saved items yet. Save items from the microsub reader, activitypub reader, or frontend pages.{% endif %}

+
+ {% endif %} +
+ + +{% endblock %} +``` + +**Step 2: Create the stylesheet** + +```css +/* Read It Later admin styles */ + +.readlater__header { + margin-bottom: var(--space-m); +} + +.readlater__filters { + align-items: flex-end; + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + margin-bottom: var(--space-l); +} + +.readlater__filter-group { + display: flex; + flex-direction: column; + gap: var(--space-2xs); +} + +.readlater__filter-group--search { + flex: 1; + min-width: 200px; +} + +.readlater__filter-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary, #666); +} + +.readlater__select, +.readlater__input { + border: 1px solid var(--color-border, #ddd); + border-radius: 4px; + font-size: 0.875rem; + padding: 0.375rem 0.5rem; +} + +.readlater__list { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--color-border, #ddd); + border: 1px solid var(--color-border, #ddd); + border-radius: 4px; + overflow: hidden; +} + +.readlater__item { + align-items: center; + background: var(--color-background, #fff); + display: flex; + gap: var(--space-s); + padding: var(--space-s) var(--space-m); +} + +.readlater__item-content { + flex: 1; + min-width: 0; +} + +.readlater__item-title { + color: var(--color-text, #333); + display: block; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.readlater__item-title:hover { + color: var(--color-accent, #00f); +} + +.readlater__item-meta { + align-items: center; + display: flex; + gap: var(--space-s); + margin-top: var(--space-2xs); +} + +.readlater__source-badge { + border-radius: 3px; + color: #fff; + display: inline-block; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1; + padding: 2px 6px; + text-transform: uppercase; + background: #888; +} + +.readlater__source-badge--microsub { background: #4a9eff; } +.readlater__source-badge--activitypub { background: #6364ff; } +.readlater__source-badge--blogroll { background: #10b981; } +.readlater__source-badge--podroll { background: #f59e0b; } +.readlater__source-badge--listening { background: #ec4899; } +.readlater__source-badge--news { background: #ef4444; } + +.readlater__item-date { + color: var(--color-text-secondary, #666); + font-size: 0.75rem; +} + +.readlater__delete { + background: none; + border: 1px solid transparent; + border-radius: 4px; + color: var(--color-text-secondary, #666); + cursor: pointer; + flex-shrink: 0; + padding: 0.25rem; +} + +.readlater__delete:hover { + border-color: var(--color-border, #ddd); + color: #dc2626; +} + +.readlater__empty { + color: var(--color-text-secondary, #666); + padding: var(--space-l); + text-align: center; +} +``` + +**Step 3: Commit** + +```bash +git add views/readlater.njk assets/styles.css +git commit -m "feat: add admin page template and styles" +``` + +--- + +### Task 5: Plugin entry point — index.js + +**Files:** +- Create: `index.js` + +**Step 1: Create the plugin entry point** + +Follows the same pattern as blogroll and microsub plugins. + +```javascript +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import express from "express"; + +import { readlaterController } from "./lib/controllers/readlater.js"; +import { createIndexes } from "./lib/storage/items.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const defaults = { + mountPath: "/readlater", +}; + +const router = express.Router(); + +export default class ReadLaterEndpoint { + name = "Read It Later endpoint"; + + constructor(options = {}) { + this.options = { ...defaults, ...options }; + this.mountPath = this.options.mountPath; + } + + get localesDirectory() { + return path.join(__dirname, "locales"); + } + + get navigationItems() { + return { + href: this.options.mountPath, + text: "readlater.title", + requiresDatabase: true, + }; + } + + get shortcutItems() { + return { + url: this.options.mountPath, + name: "readlater.title", + iconName: "bookmark", + requiresDatabase: true, + }; + } + + get routes() { + router.get("/", readlaterController.list); + router.post("/save", readlaterController.save); + router.post("/delete", readlaterController.remove); + return router; + } + + init(Indiekit) { + console.info("[ReadLater] Initializing read-it-later plugin"); + + Indiekit.addCollection("readlater_items"); + Indiekit.addEndpoint(this); + + // Store mount path in application config for other plugins to detect + Indiekit.config.application.readlaterEndpoint = this.mountPath; + + if (Indiekit.database) { + createIndexes(Indiekit).catch((error) => { + console.warn("[ReadLater] Index creation failed:", error.message); + }); + } + } +} +``` + +**Step 2: Commit** + +```bash +git add index.js +git commit -m "feat: add plugin entry point with routes and init" +``` + +--- + +### Task 6: Locale file — en.json + +**Files:** +- Create: `locales/en.json` + +**Step 1: Create English locale** + +```json +{ + "readlater": { + "title": "Read It Later", + "empty": "No saved items yet.", + "emptyFiltered": "No items match your filters.", + "save": "Save for later", + "saved": "Saved", + "remove": "Remove", + "filters": { + "sort": "Sort", + "source": "Source", + "search": "Search", + "allSources": "All sources", + "newestFirst": "Newest first", + "oldestFirst": "Oldest first", + "apply": "Filter", + "clear": "Clear" + } + } +} +``` + +**Step 2: Commit** + +```bash +git add locales/en.json +git commit -m "feat: add English locale strings" +``` + +--- + +### Task 7: CLAUDE.md and README + +**Files:** +- Create: `CLAUDE.md` + +**Step 1: Create CLAUDE.md** + +```markdown +# CLAUDE.md - indiekit-endpoint-readlater + +## Package Overview + +`@rmdes/indiekit-endpoint-readlater` is a "Read It Later" plugin for Indiekit. It provides a private bookmark list where you can save URLs from any context (microsub reader, activitypub reader, blogroll, podroll, listening, news) for later consumption. + +**Package Name:** `@rmdes/indiekit-endpoint-readlater` +**Type:** ESM module +**Entry Point:** `index.js` + +## MongoDB Collection + +### `readlater_items` + +```javascript +{ + _id: ObjectId, + url: "https://example.com/article", // Unique — prevents duplicates + title: "Article Title", // Display title + source: "microsub" | "activitypub" | "blogroll" | "podroll" | "listening" | "news" | "manual", + savedAt: "2026-02-27T12:00:00.000Z", // ISO 8601 string +} +``` + +**Indexes:** +- `{ url: 1 }` unique — deduplication +- `{ savedAt: -1 }` — sort by date +- `{ source: 1 }` — filter by source + +## API Endpoints + +All routes require authentication. + +| Method | Path | Body | Response | +|--------|------|------|----------| +| GET | `/readlater` | — | Admin page (HTML) | +| POST | `/readlater/save` | `{url, title, source}` | `{success, item, alreadySaved?}` | +| POST | `/readlater/delete` | `{id}` or `{url}` | `{success}` or `{error}` | + +## Integration with Other Plugins + +Other plugins detect this plugin by checking `application.readlaterEndpoint`. If set, they render a save button. If not, no button appears. + +### How to add a save button in another plugin's template: + +```nunjucks +{% if application.readlaterEndpoint %} + +{% endif %} +``` + +```javascript +button.addEventListener('click', async () => { + const response = await fetch('/readlater/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, title, source: 'pluginName' }), + credentials: 'same-origin' + }); +}); +``` + +## Key Files + +- `index.js` — Plugin entry point, routes, init +- `lib/storage/items.js` — MongoDB CRUD operations +- `lib/controllers/readlater.js` — Admin page and API handlers +- `views/readlater.njk` — Admin page template +- `assets/styles.css` — Admin page styles +- `locales/en.json` — English locale strings +``` + +**Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: add CLAUDE.md" +``` + +--- + +### Task 8: Install dependencies and verify plugin loads + +**Step 1: Install dependencies** + +```bash +cd /home/rick/code/indiekit-dev/indiekit-endpoint-readlater +npm install +``` + +**Step 2: Verify the module exports correctly** + +```bash +node -e "import('./index.js').then(m => { const e = new m.default(); console.log(e.name, e.mountPath); })" +``` + +Expected: `Read It Later endpoint /readlater` + +**Step 3: Commit lock file** + +```bash +git add package-lock.json +git commit -m "chore: add package-lock.json" +``` + +--- + +## Phase 2: Microsub Reader Integration + +> These changes are in `/home/rick/code/indiekit-dev/indiekit-endpoint-microsub/` + +### Task 9: Add save button to microsub item-card + +**Files:** +- Modify: `views/partials/item-card.njk:170-211` (action bar) + +**Step 1: Add save-for-later button to item-card action bar** + +In `views/partials/item-card.njk`, inside the `
` block (after the mark-read button, around line 210), add: + +```nunjucks + {% if application.readlaterEndpoint %} + + {% endif %} +``` + +**Step 2: Add save-later JS handler to timeline.njk and channel.njk** + +In both `views/timeline.njk` and `views/channel.njk`, inside the existing ` +``` + +**Step 4: Commit** + +```bash +cd /home/rick/code/indiekit-dev/indiekit-eleventy-theme +git add js/save-later.js css/tailwind.css _includes/layouts/base.njk +git commit -m "feat: add shared save-for-later module for frontend pages" +``` + +--- + +### Task 12: Add save buttons to blogroll page + +**Files:** +- Modify: `blogroll.njk` — category items loop (around line 111-181) + +**Step 1: Add save button to each blog post item** + +Inside the `