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 %}
-
-
-{{ title }}
-