From 90b47627e250bad1afe918f58d3e5f1efcbb2e3b Mon Sep 17 00:00:00 2001 From: Ricardo Date: Fri, 27 Feb 2026 17:18:28 +0100 Subject: [PATCH] chore: remove dev plans from published repo Plans moved to central /home/rick/code/indiekit-dev/docs/plans/ --- docs/plans/2026-02-27-read-it-later-design.md | 111 -- docs/plans/2026-02-27-read-it-later-plan.md | 1386 ----------------- 2 files changed, 1497 deletions(-) delete mode 100644 docs/plans/2026-02-27-read-it-later-design.md delete mode 100644 docs/plans/2026-02-27-read-it-later-plan.md diff --git a/docs/plans/2026-02-27-read-it-later-design.md b/docs/plans/2026-02-27-read-it-later-design.md deleted file mode 100644 index 41f4305..0000000 --- a/docs/plans/2026-02-27-read-it-later-design.md +++ /dev/null @@ -1,111 +0,0 @@ -# Read It Later — Design Document - -**Date:** 2026-02-27 -**Status:** Approved - -## Goal - -A standalone Indiekit plugin (`@rmdes/indiekit-endpoint-readlater`) that provides a private "read it later" bookmark list. Save URLs from any context — backend readers (microsub, activitypub) and frontend pages (blogroll, podroll, listening, news) — into a unified collection for later consumption. - -## Architecture - -A minimal standalone plugin owns a single MongoDB collection and exposes a save/delete API. Other plugins and the Eleventy theme add per-item save icons that POST to this API. The plugin has its own admin page for managing saved items. No content is copied — only URLs with metadata. - -## Data Model - -**Collection:** `readlater_items` - -```javascript -{ - _id: ObjectId, - url: "https://example.com/article", // Unique key — 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 -} -``` - -**Index:** `{ url: 1 }` unique. - -## API Endpoints - -All routes require authentication. No public routes. - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/readlater` | Admin page — list saved items with filters | -| POST | `/readlater/save` | Save a URL — accepts `{url, title, source}`, returns JSON | -| POST | `/readlater/delete` | Delete a saved item — accepts `{url}` or `{id}`, returns JSON | - -**Save response:** `{success: true, item: {...}}` or `{error: "Already saved"}`. - -**Delete response:** `{success: true}` or `{error: "Not found"}`. - -## Admin Page (`/readlater`) - -List view showing all saved items with: - -- **Sort toggle:** Newest first (default) / oldest first — `?sort=asc|desc` -- **Source filter:** Dropdown showing only sources with saved items — `?source=microsub` -- **Search box:** Text search across title and URL — `?q=search+term` -- **Per-item display:** Title (linked to original URL, new tab), source badge, saved date, delete button -- **Delete animation:** Fade-out on delete, same pattern as microsub mark-read - -Filters work via query parameters (bookmarkable, no JS required for filtering). - -**Navigation:** Sidebar entry under "Read & Engage". - -## Integration Strategy - -### Detection - -Each consuming plugin checks at startup/render whether `@rmdes/indiekit-endpoint-readlater` is installed. If not installed, save buttons don't render. No hard dependency. - -### Button Behavior - -1. Click save icon -> POST to `/readlater/save` -> icon changes to filled/checkmark state -2. Already saved -> API returns "already saved" -> icon stays in saved state -3. No unsave from item cards — manage saved items from `/readlater` admin page - -## Phase 1: Plugin + Backend Readers - -**New plugin:** `@rmdes/indiekit-endpoint-readlater` -- MongoDB collection, indexes -- Save/delete API endpoints -- Admin page with filters - -**Microsub reader** (`indiekit-endpoint-microsub`): -- Add save icon to `item-card.njk` action bar (alongside reply, like, repost, bookmark, mark-read) -- Button sends `{url: item.url, title: item.name, source: "microsub"}` - -**ActivityPub reader** (`indiekit-endpoint-activitypub`): -- Add save icon to post action bar (alongside reply, boost, like, view original) -- Button sends `{url: originalPostUrl, title: contentSnippet, source: "activitypub"}` - -## Phase 2: Frontend Theme Integration - -**Repo:** `indiekit-eleventy-theme` (separate from plugin) - -Add per-item save icons to frontend pages, only visible when logged in: - -- `/blogroll/` — per blog post link -- `/podroll` — per episode link -- `/listening/` — per track/listen -- `/news` — per RSS item - -Same API call (`POST /readlater/save`), same button behavior. - -Auth gating uses the same mechanism as the existing "Create new post" FAB. - -## Lifecycle - -- Items persist until manually deleted -- No auto-expiry, no archiving -- No content storage — just URL bookmarks - -## Tech Stack - -- Express routes (Indiekit plugin API) -- MongoDB (single collection) -- Nunjucks templates (@indiekit/frontend layout) -- Vanilla JS for save button fetch calls diff --git a/docs/plans/2026-02-27-read-it-later-plan.md b/docs/plans/2026-02-27-read-it-later-plan.md deleted file mode 100644 index 4e1762c..0000000 --- a/docs/plans/2026-02-27-read-it-later-plan.md +++ /dev/null @@ -1,1386 +0,0 @@ -# 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 `