From 4ad4c13bbc81b92616ab1b19972ce4b5a88c7eff Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 12 Feb 2026 18:42:27 +0100 Subject: [PATCH] refactor: align views with upstream @indiekit/frontend patterns - Extract ~560 lines of inline CSS to external assets/styles.css - Create intermediate layout (layouts/blogroll.njk) for CSS loading - Use section(), badge(), button(), prose() macros instead of raw HTML - Remove custom page headers (document.njk heading() handles via title/parent) - Add parent breadcrumb navigation to all sub-pages - Add consumeFlashMessage() to dashboard and sources controllers - Rename CSS class prefix from br-* to blogroll-* for clarity - Use upstream CSS custom properties without fallback values - Fix Microsub orphan detection (soft-delete unsubscribed blogs) - Fix upsert to conditionally set microsub fields (avoid path conflicts) - Skip soft-deleted blogs during clear-and-resync Co-Authored-By: Claude Opus 4.6 --- assets/styles.css | 342 ++++++++++++++++++++++++++++ lib/controllers/blogs.js | 3 + lib/controllers/dashboard.js | 19 ++ lib/controllers/sources.js | 22 ++ lib/storage/blogs.js | 67 ++++-- lib/sync/microsub.js | 32 ++- lib/sync/scheduler.js | 4 +- package.json | 2 +- views/blogroll-blog-edit.njk | 399 ++++++++------------------------- views/blogroll-blogs.njk | 171 +++----------- views/blogroll-dashboard.njk | 257 +++++---------------- views/blogroll-source-edit.njk | 198 ++++++---------- views/blogroll-sources.njk | 125 ++--------- views/layouts/blogroll.njk | 6 + 14 files changed, 722 insertions(+), 925 deletions(-) create mode 100644 assets/styles.css create mode 100644 views/layouts/blogroll.njk diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..de8562d --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,342 @@ +/* Blogroll endpoint styles */ + +/* Stats grid */ +.blogroll-stats { + display: grid; + gap: var(--space-s); + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); +} + +.blogroll-stat { + background: var(--color-background); + border-radius: var(--radius-s); + padding: var(--space-s); + text-align: center; +} + +.blogroll-stat dt { + color: var(--color-text-secondary); + font-size: var(--step--1); + margin-block-end: var(--space-2xs); +} + +.blogroll-stat dd { + font-size: var(--step-0); + font-weight: var(--font-weight-semibold); + margin: 0; +} + +.blogroll-stat dd.blogroll-stat--error { + color: var(--color-error); +} + +/* Quick links (button row) */ +.blogroll-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + margin-block-start: var(--space-m); +} + +/* List (shared between blogs, sources, errors, dashboard lists) */ +.blogroll-list { + display: flex; + flex-direction: column; + gap: var(--space-s); + list-style: none; + margin: 0; + padding: 0; +} + +.blogroll-list__item { + align-items: flex-start; + background: var(--color-offset); + border-radius: var(--radius-m); + display: flex; + flex-wrap: wrap; + gap: var(--space-s); + justify-content: space-between; + padding: var(--space-m); +} + +.blogroll-list__item--compact { + padding: var(--space-xs) var(--space-s); +} + +.blogroll-list__item--pinned { + border-inline-start: 3px solid var(--color-accent); +} + +.blogroll-list__item--hidden { + opacity: 0.6; +} + +/* Item content (left side) */ +.blogroll-item__info { + flex: 1; + min-inline-size: 200px; +} + +.blogroll-item__title { + font-size: var(--step-0); + font-weight: var(--font-weight-semibold); + margin: 0 0 var(--space-2xs); +} + +.blogroll-item__title a { + color: inherit; + text-decoration: none; +} + +.blogroll-item__title a:hover { + text-decoration: underline; +} + +.blogroll-item__meta { + align-items: center; + color: var(--color-text-secondary); + display: flex; + flex-wrap: wrap; + font-size: var(--step--1); + gap: var(--space-xs); +} + +.blogroll-item__url { + color: var(--color-accent); + font-family: monospace; + font-size: var(--step--2); + margin-block-start: var(--space-2xs); + word-break: break-all; +} + +.blogroll-item__error { + color: var(--color-error); + font-size: var(--step--1); + margin-block-start: var(--space-2xs); +} + +/* Item actions (right side) */ +.blogroll-item__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +/* Filters */ +.blogroll-filters { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-s); +} + +.blogroll-filter-select { + appearance: none; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-s); + font-size: var(--step--1); + min-inline-size: 150px; + padding: var(--space-2xs) var(--space-s); +} + +/* Form fields */ +.blogroll-form { + max-inline-size: 600px; +} + +.blogroll-field { + display: flex; + flex-direction: column; + gap: var(--space-2xs); + margin-block-end: var(--space-m); +} + +.blogroll-field label { + font-weight: var(--font-weight-semibold); +} + +.blogroll-field-hint { + color: var(--color-text-secondary); + font-size: var(--step--1); +} + +.blogroll-field input, +.blogroll-field select, +.blogroll-field textarea { + appearance: none; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-s); + font-size: var(--step--1); + padding: var(--space-2xs) var(--space-s); + width: 100%; +} + +.blogroll-field textarea { + min-block-size: 100px; +} + +.blogroll-field input:focus, +.blogroll-field select:focus, +.blogroll-field textarea:focus { + border-color: var(--color-accent); + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.blogroll-field--inline { + align-items: center; + flex-direction: row; + gap: var(--space-s); +} + +.blogroll-field--inline input[type="checkbox"] { + appearance: auto; + cursor: pointer; + width: auto; +} + +/* API list */ +.blogroll-api-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + list-style: none; + margin: 0; + padding: 0; +} + +.blogroll-api-list li { + background: var(--color-background); + border-radius: var(--radius-s); + font-size: var(--step--1); + padding: var(--space-xs) var(--space-s); +} + +.blogroll-api-list code { + color: var(--color-accent); + font-weight: var(--font-weight-semibold); +} + +/* Feed items (inside blog-edit) */ +.blogroll-items-list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + list-style: none; + margin: 0; + padding: 0; +} + +.blogroll-items-list li { + background: var(--color-offset); + border-radius: var(--radius-s); + padding: var(--space-xs) var(--space-s); +} + +.blogroll-items-list .blogroll-item__title { + font-size: var(--step--1); + margin: 0; +} + +.blogroll-items-list .blogroll-item__meta { + font-size: var(--step--2); +} + +/* Feed discovery (blog-edit new) */ +.blogroll-discover { + background: var(--color-offset); + border-radius: var(--radius-m); + margin-block-end: var(--space-m); + padding: var(--space-m); +} + +.blogroll-discover .blogroll-field { + margin-block-end: var(--space-s); +} + +.blogroll-discover__input { + display: flex; + gap: var(--space-s); +} + +.blogroll-discover__input input { + appearance: none; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-s); + flex: 1; + font-size: var(--step--1); + padding: var(--space-2xs) var(--space-s); +} + +.blogroll-discover__result { + background: var(--color-background); + border-radius: var(--radius-s); + font-size: var(--step--1); + margin-block-start: var(--space-s); + padding: var(--space-s); +} + +.blogroll-discover__result--error { + color: var(--color-error); +} + +.blogroll-discover__result--success { + color: var(--color-success); +} + +.blogroll-discover__feeds { + display: flex; + flex-direction: column; + gap: var(--space-xs); + list-style: none; + margin: var(--space-xs) 0 0; + padding: 0; +} + +.blogroll-discover__feed { + align-items: center; + background: var(--color-offset); + border-radius: var(--radius-s); + cursor: pointer; + display: flex; + gap: var(--space-s); + padding: var(--space-xs); +} + +.blogroll-discover__feed:hover { + opacity: 0.8; +} + +.blogroll-discover__feed-url { + flex: 1; + font-family: monospace; + font-size: var(--step--2); + word-break: break-all; +} + +.blogroll-discover__feed-type { + background: var(--color-accent); + border-radius: var(--radius-s); + color: var(--color-on-accent); + font-size: var(--step--2); + padding: var(--space-3xs) var(--space-2xs); + text-transform: uppercase; +} + +/* Empty state */ +.blogroll-empty { + color: var(--color-text-secondary); + font-size: var(--step--1); + padding: var(--space-m); + text-align: center; +} + +/* Divider */ +.blogroll-divider { + border: none; + border-block-start: 1px solid var(--color-border); + margin: var(--space-m) 0; +} diff --git a/lib/controllers/blogs.js b/lib/controllers/blogs.js index f5be016..d9e512d 100644 --- a/lib/controllers/blogs.js +++ b/lib/controllers/blogs.js @@ -43,6 +43,7 @@ async function list(request, response) { response.render("blogroll-blogs", { title: request.__("blogroll.blogs.title"), + parent: { text: request.__("blogroll.title"), href: request.baseUrl }, blogs: filteredBlogs, categories, filterCategory: category, @@ -66,6 +67,7 @@ async function list(request, response) { function newForm(request, response) { response.render("blogroll-blog-edit", { title: request.__("blogroll.blogs.new"), + parent: { text: request.__("blogroll.blogs.title"), href: `${request.baseUrl}/blogs` }, blog: null, isNew: true, baseUrl: request.baseUrl, @@ -175,6 +177,7 @@ async function edit(request, response) { response.render("blogroll-blog-edit", { title: request.__("blogroll.blogs.edit"), + parent: { text: request.__("blogroll.blogs.title"), href: `${request.baseUrl}/blogs` }, blog, items, isNew: false, diff --git a/lib/controllers/dashboard.js b/lib/controllers/dashboard.js index 1cfddb0..8c4eab7 100644 --- a/lib/controllers/dashboard.js +++ b/lib/controllers/dashboard.js @@ -38,6 +38,9 @@ async function get(request, response) { const errorBlogs = await getBlogs(application, { includeHidden: true, limit: 100 }); const blogsWithErrors = errorBlogs.filter((b) => b.status === "error"); + // Extract flash messages for native Indiekit notification banner + const flash = consumeFlashMessage(request); + response.render("blogroll-dashboard", { title: request.__("blogroll.title"), sources, @@ -51,6 +54,7 @@ async function get(request, response) { syncStatus, blogsWithErrors: blogsWithErrors.slice(0, 5), baseUrl: request.baseUrl, + ...flash, }); } catch (error) { console.error("[Blogroll] Dashboard error:", error); @@ -151,6 +155,21 @@ async function status(request, response) { } } +/** + * Extract and clear flash messages from session + * Returns { success, error } for Indiekit's native notificationBanner + */ +function consumeFlashMessage(request) { + const result = {}; + if (request.session?.messages?.length) { + const msg = request.session.messages[0]; + if (msg.type === "success") result.success = msg.content; + else if (msg.type === "error" || msg.type === "warning") result.error = msg.content; + request.session.messages = null; + } + return result; +} + export const dashboardController = { get, sync, diff --git a/lib/controllers/sources.js b/lib/controllers/sources.js index 3a013f6..8558e5f 100644 --- a/lib/controllers/sources.js +++ b/lib/controllers/sources.js @@ -37,10 +37,15 @@ async function list(request, response) { : null, })); + // Extract flash messages for native Indiekit notification banner + const flash = consumeFlashMessage(request); + response.render("blogroll-sources", { title: request.__("blogroll.sources.title"), + parent: { text: request.__("blogroll.title"), href: request.baseUrl }, sources, baseUrl: request.baseUrl, + ...flash, }); } catch (error) { console.error("[Blogroll] Sources list error:", error); @@ -66,6 +71,7 @@ async function newForm(request, response) { response.render("blogroll-source-edit", { title: request.__("blogroll.sources.new"), + parent: { text: request.__("blogroll.sources.title"), href: `${request.baseUrl}/sources` }, source: null, isNew: true, baseUrl: request.baseUrl, @@ -185,6 +191,7 @@ async function edit(request, response) { response.render("blogroll-source-edit", { title: request.__("blogroll.sources.edit"), + parent: { text: request.__("blogroll.sources.title"), href: `${request.baseUrl}/sources` }, source, isNew: false, baseUrl: request.baseUrl, @@ -336,6 +343,21 @@ async function sync(request, response) { } } +/** + * Extract and clear flash messages from session + * Returns { success, error } for Indiekit's native notificationBanner + */ +function consumeFlashMessage(request) { + const result = {}; + if (request.session?.messages?.length) { + const msg = request.session.messages[0]; + if (msg.type === "success") result.success = msg.content; + else if (msg.type === "error" || msg.type === "warning") result.error = msg.content; + request.session.messages = null; + } + return result; +} + export const sourcesController = { list, newForm, diff --git a/lib/storage/blogs.js b/lib/storage/blogs.js index a5b7770..33f7a99 100644 --- a/lib/storage/blogs.js +++ b/lib/storage/blogs.js @@ -262,31 +262,54 @@ export async function upsertBlog(application, data) { filter.sourceId = new ObjectId(data.sourceId); } + // Build $set with base fields + const setFields = { + title: data.title, + siteUrl: data.siteUrl, + feedType: data.feedType, + category: data.category, + sourceId: data.sourceId ? new ObjectId(data.sourceId) : null, + updatedAt: now, + }; + + // Conditionally add microsub/optional fields to $set when provided + if (data.source !== undefined) setFields.source = data.source; + if (data.microsubFeedId !== undefined) setFields.microsubFeedId = data.microsubFeedId; + if (data.microsubChannelId !== undefined) setFields.microsubChannelId = data.microsubChannelId; + if (data.microsubChannelName !== undefined) setFields.microsubChannelName = data.microsubChannelName; + if (data.skipItemFetch !== undefined) setFields.skipItemFetch = data.skipItemFetch; + if (data.photo !== undefined) setFields.photo = data.photo; + if (data.lastFetchAt !== undefined) setFields.lastFetchAt = data.lastFetchAt; + if (data.status !== undefined) setFields.status = data.status; + + // $setOnInsert only for fields NOT already in $set (avoids MongoDB path conflicts) + const insertDefaults = { + description: null, + tags: [], + author: null, + lastError: null, + itemCount: 0, + pinned: false, + hidden: false, + notes: null, + createdAt: now, + }; + + // Add defaults for optional fields only when they're NOT in $set + if (!("source" in setFields)) insertDefaults.source = null; + if (!("microsubFeedId" in setFields)) insertDefaults.microsubFeedId = null; + if (!("microsubChannelId" in setFields)) insertDefaults.microsubChannelId = null; + if (!("microsubChannelName" in setFields)) insertDefaults.microsubChannelName = null; + if (!("skipItemFetch" in setFields)) insertDefaults.skipItemFetch = false; + if (!("photo" in setFields)) insertDefaults.photo = null; + if (!("lastFetchAt" in setFields)) insertDefaults.lastFetchAt = null; + if (!("status" in setFields)) insertDefaults.status = "active"; + const result = await collection.updateOne( filter, { - $set: { - title: data.title, - siteUrl: data.siteUrl, - feedType: data.feedType, - category: data.category, - sourceId: data.sourceId ? new ObjectId(data.sourceId) : null, - updatedAt: now, - }, - $setOnInsert: { - description: null, - tags: [], - photo: null, - author: null, - status: "active", - lastFetchAt: null, - lastError: null, - itemCount: 0, - pinned: false, - hidden: false, - notes: null, - createdAt: now, - }, + $set: setFields, + $setOnInsert: insertDefaults, }, { upsert: true } ); diff --git a/lib/sync/microsub.js b/lib/sync/microsub.js index cbf67f0..890b747 100644 --- a/lib/sync/microsub.js +++ b/lib/sync/microsub.js @@ -43,6 +43,7 @@ export async function syncMicrosubSource(application, source) { let added = 0; let updated = 0; let total = 0; + const currentMicrosubFeedIds = []; for (const channel of channels) { // Get all feeds subscribed in this channel @@ -50,6 +51,7 @@ export async function syncMicrosubSource(application, source) { for (const feed of feeds) { total++; + currentMicrosubFeedIds.push(feed._id.toString()); // Store REFERENCE to Microsub feed, not a copy // Items will be queried from microsub_items directly @@ -83,14 +85,40 @@ export async function syncMicrosubSource(application, source) { } } + // Orphan detection: soft-delete blogs whose microsub feed no longer exists + let orphaned = 0; + if (currentMicrosubFeedIds.length > 0) { + const db = application.getBlogrollDb(); + const orphanResult = await db.collection("blogrollBlogs").updateMany( + { + source: "microsub", + sourceId: source._id, + microsubFeedId: { $nin: currentMicrosubFeedIds }, + status: { $ne: "deleted" }, + }, + { + $set: { + status: "deleted", + hidden: true, + deletedAt: new Date(), + updatedAt: new Date(), + }, + } + ); + orphaned = orphanResult.modifiedCount; + if (orphaned > 0) { + console.log(`[Blogroll] Cleaned up ${orphaned} orphaned Microsub blog(s) no longer subscribed`); + } + } + // Update source sync status await updateSourceSyncStatus(application, source._id, { success: true }); console.log( - `[Blogroll] Synced Microsub source "${source.name}": ${added} added, ${updated} updated, ${total} total from ${channels.length} channels (items served from Microsub)` + `[Blogroll] Synced Microsub source "${source.name}": ${added} added, ${updated} updated, ${orphaned} orphaned, ${total} total from ${channels.length} channels (items served from Microsub)` ); - return { success: true, added, updated, total }; + return { success: true, added, updated, orphaned, total }; } catch (error) { // Update source with error status await updateSourceSyncStatus(application, source._id, { diff --git a/lib/sync/scheduler.js b/lib/sync/scheduler.js index b50e93c..a320e73 100644 --- a/lib/sync/scheduler.js +++ b/lib/sync/scheduler.js @@ -198,9 +198,9 @@ export async function clearAndResync(application, options = {}) { // Clear all items (but keep blogs and sources) await db.collection("blogrollItems").deleteMany({}); - // Reset blog item counts and status + // Reset blog item counts and status (skip soft-deleted blogs) await db.collection("blogrollBlogs").updateMany( - {}, + { status: { $ne: "deleted" } }, { $set: { itemCount: 0, diff --git a/package.json b/package.json index 7e5e800..ad8fa9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-blogroll", - "version": "1.0.12", + "version": "1.0.15", "description": "Blogroll endpoint for Indiekit. Aggregates blog feeds from OPML, JSON feeds, or manual entry.", "keywords": [ "indiekit", diff --git a/views/blogroll-blog-edit.njk b/views/blogroll-blog-edit.njk index ac153df..0979e47 100644 --- a/views/blogroll-blog-edit.njk +++ b/views/blogroll-blog-edit.njk @@ -1,304 +1,95 @@ -{% extends "document.njk" %} +{% extends "layouts/blogroll.njk" %} -{% block content %} - - - - -{# Flash messages rendered by Indiekit's native notificationBanner via success/error template vars #} - -
- {% if isNew %} -
-
- -
- - +{% block blogroll %} + + {% if isNew %} +
+
+ +
+ + {{ button({ type: "button", text: __("blogroll.blogs.form.discover"), classes: "button--secondary", attributes: { id: "discoverBtn" } }) }} +
+ {{ __("blogroll.blogs.form.discoverHint") }}
- {{ __("blogroll.blogs.form.discoverHint") }} +
- -
-
+
+ {% endif %} + +
+ + + {{ __("blogroll.blogs.form.feedUrlHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.titleHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.siteUrlHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.categoryHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.tagsHint") }} +
+ +
+ + + {{ __("blogroll.blogs.form.notesHint") }} +
+ +
+ + +
+ +
+ + +
+ +
+ {{ button({ type: "submit", text: __("blogroll.blogs.create") if isNew else __("blogroll.blogs.save") }) }} + {{ button({ href: baseUrl + "/blogs", text: __("blogroll.cancel"), classes: "button--secondary" }) }} +
+ + + {% if not isNew and items %} + {% call section({ title: __("blogroll.blogs.recentItems") }) %} + {% if items.length > 0 %} +
    + {% for item in items %} +
  • + +
    + {% if item.published %}{{ item.published | date("PPpp") }}{% endif %} +
    +
  • + {% endfor %} +
+ {% else %} + {{ prose({ text: __("blogroll.blogs.noItems") }) }} + {% endif %} + {% endcall %} {% endif %} -
- - - {{ __("blogroll.blogs.form.feedUrlHint") }} -
- -
- - - {{ __("blogroll.blogs.form.titleHint") }} -
- -
- - - {{ __("blogroll.blogs.form.siteUrlHint") }} -
- -
- - - {{ __("blogroll.blogs.form.categoryHint") }} -
- -
- - - {{ __("blogroll.blogs.form.tagsHint") }} -
- -
- - - {{ __("blogroll.blogs.form.notesHint") }} -
- -
- - -
- -
- - -
- -
- - {{ __("blogroll.cancel") }} -
- - -{% if not isNew and items %} -
-

{{ __("blogroll.blogs.recentItems") }}

- {% if items.length > 0 %} -
    - {% for item in items %} -
  • - -
    - {{ item.published | date("PPpp") }} -
    -
  • - {% endfor %} -
- {% else %} -

{{ __("blogroll.blogs.noItems") }}

- {% endif %} -
-{% endif %} - {% if isNew %}