Files
Ricardo 4ad4c13bbc 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 <noreply@anthropic.com>
2026-02-12 18:42:27 +01:00

332 lines
8.5 KiB
JavaScript

/**
* Blogs controller
* @module controllers/blogs
*/
import {
getBlogs,
getBlog,
getBlogByFeedUrl,
createBlog,
updateBlog,
deleteBlog,
} from "../storage/blogs.js";
import { getItemsForBlog, deleteItemsForBlog } from "../storage/items.js";
import { syncBlogItems } from "../sync/feed.js";
/**
* List blogs
* GET /blogs
*/
async function list(request, response) {
const { application } = request.app.locals;
const { category, status: filterStatus } = request.query;
try {
const blogs = await getBlogs(application, {
category,
includeHidden: true,
limit: 100,
});
// Filter by status if specified
let filteredBlogs = blogs;
if (filterStatus) {
filteredBlogs = blogs.filter((b) => b.status === filterStatus);
}
// Get unique categories for filter dropdown
const categories = [...new Set(blogs.map((b) => b.category).filter(Boolean))];
// Extract flash messages for native Indiekit notification banner
const flash = consumeFlashMessage(request);
response.render("blogroll-blogs", {
title: request.__("blogroll.blogs.title"),
parent: { text: request.__("blogroll.title"), href: request.baseUrl },
blogs: filteredBlogs,
categories,
filterCategory: category,
filterStatus,
baseUrl: request.baseUrl,
...flash,
});
} catch (error) {
console.error("[Blogroll] Blogs list error:", error);
response.status(500).render("error", {
title: "Error",
message: "Failed to load blogs",
});
}
}
/**
* New blog form
* GET /blogs/new
*/
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,
});
}
/**
* Create blog
* POST /blogs
*/
async function create(request, response) {
const { application } = request.app.locals;
const { feedUrl, title, siteUrl, category, tags, notes, pinned, hidden } = request.body;
try {
// Validate required fields
if (!feedUrl) {
request.session.messages = [
{ type: "error", content: "Feed URL is required" },
];
return response.redirect(`${request.baseUrl}/blogs/new`);
}
// Check for duplicate
const existing = await getBlogByFeedUrl(application, feedUrl);
if (existing) {
request.session.messages = [
{ type: "error", content: "A blog with this feed URL already exists" },
];
return response.redirect(`${request.baseUrl}/blogs/new`);
}
const blog = await createBlog(application, {
feedUrl,
title: title || feedUrl,
siteUrl: siteUrl || null,
category: category || "",
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
notes: notes || null,
pinned: pinned === "on" || pinned === true,
hidden: hidden === "on" || hidden === true,
});
// Trigger initial fetch
try {
const result = await syncBlogItems(application, blog, application.blogrollConfig);
if (result.success) {
request.session.messages = [
{
type: "success",
content: request.__("blogroll.blogs.created_synced", { items: result.added }),
},
];
} else {
request.session.messages = [
{
type: "warning",
content: request.__("blogroll.blogs.created_sync_failed", { error: result.error }),
},
];
}
} catch (syncError) {
request.session.messages = [
{
type: "warning",
content: request.__("blogroll.blogs.created_sync_failed", { error: syncError.message }),
},
];
}
response.redirect(`${request.baseUrl}/blogs`);
} catch (error) {
console.error("[Blogroll] Create blog error:", error);
request.session.messages = [
{ type: "error", content: error.message },
];
response.redirect(`${request.baseUrl}/blogs/new`);
}
}
/**
* Edit blog form
* GET /blogs/:id
*/
async function edit(request, response) {
const { application } = request.app.locals;
const { id } = request.params;
try {
const blog = await getBlog(application, id);
if (!blog) {
return response.status(404).render("404");
}
const rawItems = await getItemsForBlog(application, blog._id, 10);
const items = rawItems.map((item) => ({
...item,
published:
item.published instanceof Date
? item.published.toISOString()
: item.published,
}));
// Extract flash messages for native Indiekit notification banner
const flash = consumeFlashMessage(request);
response.render("blogroll-blog-edit", {
title: request.__("blogroll.blogs.edit"),
parent: { text: request.__("blogroll.blogs.title"), href: `${request.baseUrl}/blogs` },
blog,
items,
isNew: false,
baseUrl: request.baseUrl,
...flash,
});
} catch (error) {
console.error("[Blogroll] Edit blog error:", error);
response.status(500).render("error", {
title: "Error",
message: "Failed to load blog",
});
}
}
/**
* Update blog
* POST /blogs/:id
*/
async function update(request, response) {
const { application } = request.app.locals;
const { id } = request.params;
const { feedUrl, title, siteUrl, category, tags, notes, pinned, hidden } = request.body;
try {
const blog = await getBlog(application, id);
if (!blog) {
return response.status(404).render("404");
}
await updateBlog(application, id, {
feedUrl,
title: title || feedUrl,
siteUrl: siteUrl || null,
category: category || "",
tags: tags ? tags.split(",").map((t) => t.trim()).filter(Boolean) : [],
notes: notes || null,
pinned: pinned === "on" || pinned === true,
hidden: hidden === "on" || hidden === true,
});
request.session.messages = [
{ type: "success", content: request.__("blogroll.blogs.updated") },
];
response.redirect(`${request.baseUrl}/blogs`);
} catch (error) {
console.error("[Blogroll] Update blog error:", error);
request.session.messages = [
{ type: "error", content: error.message },
];
response.redirect(`${request.baseUrl}/blogs/${id}`);
}
}
/**
* Delete blog
* POST /blogs/:id/delete
*/
async function remove(request, response) {
const { application } = request.app.locals;
const { id } = request.params;
try {
const blog = await getBlog(application, id);
if (!blog) {
return response.status(404).render("404");
}
await deleteBlog(application, id);
request.session.messages = [
{ type: "success", content: request.__("blogroll.blogs.deleted") },
];
response.redirect(`${request.baseUrl}/blogs`);
} catch (error) {
console.error("[Blogroll] Delete blog error:", error);
request.session.messages = [
{ type: "error", content: error.message },
];
response.redirect(`${request.baseUrl}/blogs`);
}
}
/**
* Refresh blog
* POST /blogs/:id/refresh
*/
async function refresh(request, response) {
const { application } = request.app.locals;
const { id } = request.params;
try {
const blog = await getBlog(application, id);
if (!blog) {
return response.status(404).render("404");
}
const result = await syncBlogItems(application, blog, application.blogrollConfig);
if (result.success) {
request.session.messages = [
{
type: "success",
content: request.__("blogroll.blogs.refreshed", { items: result.added }),
},
];
} else {
request.session.messages = [
{ type: "error", content: result.error },
];
}
response.redirect(`${request.baseUrl}/blogs/${id}`);
} catch (error) {
console.error("[Blogroll] Refresh blog error:", error);
request.session.messages = [
{ type: "error", content: error.message },
];
response.redirect(`${request.baseUrl}/blogs/${id}`);
}
}
/**
* 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 blogsController = {
list,
newForm,
create,
edit,
update,
remove,
refresh,
};