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>
This commit is contained in:
Ricardo
2026-02-12 18:42:27 +01:00
parent 87851bade2
commit 4ad4c13bbc
14 changed files with 722 additions and 925 deletions

342
assets/styles.css Normal file
View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 }
);

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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",

View File

@@ -1,304 +1,95 @@
{% extends "document.njk" %}
{% extends "layouts/blogroll.njk" %}
{% block content %}
<style>
.br-form {
max-width: 600px;
}
.br-field {
display: flex;
flex-direction: column;
gap: var(--space-2xs, 0.25rem);
margin-block-end: var(--space-m, 1rem);
}
.br-field label {
font: var(--font-label, bold 0.875rem/1.4 sans-serif);
}
.br-field-hint {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-field input,
.br-field select,
.br-field textarea {
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
width: 100%;
}
.br-field textarea {
min-height: 100px;
}
.br-field input:focus,
.br-field select:focus,
.br-field textarea:focus {
border-color: var(--color-primary, #0066cc);
outline: 2px solid var(--color-primary, #0066cc);
outline-offset: 1px;
}
.br-field-inline {
flex-direction: row;
align-items: center;
gap: var(--space-s, 0.75rem);
}
.br-field-inline input[type="checkbox"] {
appearance: auto;
width: auto;
cursor: pointer;
}
.br-section {
margin-block-start: var(--space-l, 1.5rem);
padding-block-start: var(--space-l, 1.5rem);
border-block-start: 1px solid var(--color-outline-variant, #ddd);
}
.br-section h3 {
font: var(--font-subhead, bold 1rem/1.4 sans-serif);
margin-block-end: var(--space-s, 0.75rem);
}
.br-items-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-item {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.25rem);
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
}
.br-item-title {
font: var(--font-body, 0.875rem/1.4 sans-serif);
font-weight: 600;
}
.br-item-title a {
color: inherit;
text-decoration: none;
}
.br-item-title a:hover {
text-decoration: underline;
}
.br-item-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
}
.br-empty {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
text-align: center;
padding: var(--space-m, 1rem);
}
.br-discover-section {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1rem);
margin-block-end: var(--space-m, 1rem);
}
.br-discover-section .br-field {
margin-block-end: var(--space-s, 0.75rem);
}
.br-discover-input {
display: flex;
gap: var(--space-s, 0.75rem);
}
.br-discover-input input {
flex: 1;
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
}
.br-discover-result {
margin-block-start: var(--space-s, 0.75rem);
padding: var(--space-s, 0.75rem);
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-discover-result.br-discover-result--error {
color: var(--color-error, #dc3545);
}
.br-discover-result.br-discover-result--success {
color: var(--color-success, #28a745);
}
.br-discover-feeds {
list-style: none;
padding: 0;
margin: var(--space-xs, 0.5rem) 0 0 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-discover-feed {
display: flex;
align-items: center;
gap: var(--space-s, 0.75rem);
padding: var(--space-xs, 0.5rem);
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.25rem);
cursor: pointer;
}
.br-discover-feed:hover {
background: var(--color-primary-offset, #e6f0ff);
}
.br-discover-feed-url {
flex: 1;
font-family: monospace;
font-size: 0.75rem;
word-break: break-all;
}
.br-discover-feed-type {
background: var(--color-primary, #0066cc);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.625rem;
text-transform: uppercase;
}
.br-divider {
border: none;
border-block-start: 1px solid var(--color-outline-variant, #ddd);
margin: var(--space-m, 1rem) 0;
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}/blogs" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.blogs.title") }}</a>
<h1 class="page-header__title">{{ title }}</h1>
</header>
{# Flash messages rendered by Indiekit's native notificationBanner via success/error template vars #}
<form method="post" action="{% if isNew %}{{ baseUrl }}/blogs{% else %}{{ baseUrl }}/blogs/{{ blog._id }}{% endif %}" class="br-form">
{% if isNew %}
<div class="br-discover-section">
<div class="br-field">
<label for="discoverUrl">{{ __("blogroll.blogs.form.discoverUrl") }}</label>
<div class="br-discover-input">
<input type="url" id="discoverUrl" placeholder="https://tantek.com">
<button type="button" id="discoverBtn" class="button button--secondary">
{{ __("blogroll.blogs.form.discover") }}
</button>
{% block blogroll %}
<form method="post" action="{% if isNew %}{{ baseUrl }}/blogs{% else %}{{ baseUrl }}/blogs/{{ blog._id }}{% endif %}" class="blogroll-form">
{% if isNew %}
<div class="blogroll-discover">
<div class="blogroll-field">
<label for="discoverUrl">{{ __("blogroll.blogs.form.discoverUrl") }}</label>
<div class="blogroll-discover__input">
<input type="url" id="discoverUrl" placeholder="https://tantek.com">
{{ button({ type: "button", text: __("blogroll.blogs.form.discover"), classes: "button--secondary", attributes: { id: "discoverBtn" } }) }}
</div>
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.discoverHint") }}</span>
</div>
<span class="br-field-hint">{{ __("blogroll.blogs.form.discoverHint") }}</span>
<div id="discoverResult" class="blogroll-discover__result" style="display: none;"></div>
</div>
<div id="discoverResult" class="br-discover-result" style="display: none;"></div>
</div>
<hr class="br-divider">
<hr class="blogroll-divider">
{% endif %}
<div class="blogroll-field">
<label for="feedUrl">{{ __("blogroll.blogs.form.feedUrl") }}</label>
<input type="url" id="feedUrl" name="feedUrl" value="{{ blog.feedUrl if blog else '' }}" required placeholder="https://example.com/feed.xml">
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.feedUrlHint") }}</span>
</div>
<div class="blogroll-field">
<label for="title">{{ __("blogroll.blogs.form.title") }}</label>
<input type="text" id="title" name="title" value="{{ blog.title if blog else '' }}" placeholder="{{ __('blogroll.blogs.form.titlePlaceholder') }}">
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.titleHint") }}</span>
</div>
<div class="blogroll-field">
<label for="siteUrl">{{ __("blogroll.blogs.form.siteUrl") }}</label>
<input type="url" id="siteUrl" name="siteUrl" value="{{ blog.siteUrl if blog else '' }}" placeholder="https://example.com">
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.siteUrlHint") }}</span>
</div>
<div class="blogroll-field">
<label for="category">{{ __("blogroll.blogs.form.category") }}</label>
<input type="text" id="category" name="category" value="{{ blog.category if blog else '' }}" placeholder="Technology">
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.categoryHint") }}</span>
</div>
<div class="blogroll-field">
<label for="tags">{{ __("blogroll.blogs.form.tags") }}</label>
<input type="text" id="tags" name="tags" value="{{ blog.tags | join(', ') if blog and blog.tags else '' }}" placeholder="indie, personal, tech">
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.tagsHint") }}</span>
</div>
<div class="blogroll-field">
<label for="notes">{{ __("blogroll.blogs.form.notes") }}</label>
<textarea id="notes" name="notes" placeholder="{{ __('blogroll.blogs.form.notesPlaceholder') }}">{{ blog.notes if blog else '' }}</textarea>
<span class="blogroll-field-hint">{{ __("blogroll.blogs.form.notesHint") }}</span>
</div>
<div class="blogroll-field blogroll-field--inline">
<input type="checkbox" id="pinned" name="pinned" {% if blog and blog.pinned %}checked{% endif %}>
<label for="pinned">{{ __("blogroll.blogs.form.pinned") }}</label>
</div>
<div class="blogroll-field blogroll-field--inline">
<input type="checkbox" id="hidden" name="hidden" {% if blog and blog.hidden %}checked{% endif %}>
<label for="hidden">{{ __("blogroll.blogs.form.hidden") }}</label>
</div>
<div class="blogroll-actions">
{{ button({ type: "submit", text: __("blogroll.blogs.create") if isNew else __("blogroll.blogs.save") }) }}
{{ button({ href: baseUrl + "/blogs", text: __("blogroll.cancel"), classes: "button--secondary" }) }}
</div>
</form>
{% if not isNew and items %}
{% call section({ title: __("blogroll.blogs.recentItems") }) %}
{% if items.length > 0 %}
<ul class="blogroll-items-list">
{% for item in items %}
<li>
<div class="blogroll-item__title">
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.title }}</a>
</div>
<div class="blogroll-item__meta">
{% if item.published %}{{ item.published | date("PPpp") }}{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("blogroll.blogs.noItems") }) }}
{% endif %}
{% endcall %}
{% endif %}
<div class="br-field">
<label for="feedUrl">{{ __("blogroll.blogs.form.feedUrl") }}</label>
<input type="url" id="feedUrl" name="feedUrl" value="{{ blog.feedUrl if blog else '' }}" required placeholder="https://example.com/feed.xml">
<span class="br-field-hint">{{ __("blogroll.blogs.form.feedUrlHint") }}</span>
</div>
<div class="br-field">
<label for="title">{{ __("blogroll.blogs.form.title") }}</label>
<input type="text" id="title" name="title" value="{{ blog.title if blog else '' }}" placeholder="{{ __('blogroll.blogs.form.titlePlaceholder') }}">
<span class="br-field-hint">{{ __("blogroll.blogs.form.titleHint") }}</span>
</div>
<div class="br-field">
<label for="siteUrl">{{ __("blogroll.blogs.form.siteUrl") }}</label>
<input type="url" id="siteUrl" name="siteUrl" value="{{ blog.siteUrl if blog else '' }}" placeholder="https://example.com">
<span class="br-field-hint">{{ __("blogroll.blogs.form.siteUrlHint") }}</span>
</div>
<div class="br-field">
<label for="category">{{ __("blogroll.blogs.form.category") }}</label>
<input type="text" id="category" name="category" value="{{ blog.category if blog else '' }}" placeholder="Technology">
<span class="br-field-hint">{{ __("blogroll.blogs.form.categoryHint") }}</span>
</div>
<div class="br-field">
<label for="tags">{{ __("blogroll.blogs.form.tags") }}</label>
<input type="text" id="tags" name="tags" value="{{ blog.tags | join(', ') if blog and blog.tags else '' }}" placeholder="indie, personal, tech">
<span class="br-field-hint">{{ __("blogroll.blogs.form.tagsHint") }}</span>
</div>
<div class="br-field">
<label for="notes">{{ __("blogroll.blogs.form.notes") }}</label>
<textarea id="notes" name="notes" placeholder="{{ __('blogroll.blogs.form.notesPlaceholder') }}">{{ blog.notes if blog else '' }}</textarea>
<span class="br-field-hint">{{ __("blogroll.blogs.form.notesHint") }}</span>
</div>
<div class="br-field br-field-inline">
<input type="checkbox" id="pinned" name="pinned" {% if blog and blog.pinned %}checked{% endif %}>
<label for="pinned">{{ __("blogroll.blogs.form.pinned") }}</label>
</div>
<div class="br-field br-field-inline">
<input type="checkbox" id="hidden" name="hidden" {% if blog and blog.hidden %}checked{% endif %}>
<label for="hidden">{{ __("blogroll.blogs.form.hidden") }}</label>
</div>
<div class="button-group">
<button type="submit" class="button button--primary">
{% if isNew %}{{ __("blogroll.blogs.create") }}{% else %}{{ __("blogroll.blogs.save") }}{% endif %}
</button>
<a href="{{ baseUrl }}/blogs" class="button button--secondary">{{ __("blogroll.cancel") }}</a>
</div>
</form>
{% if not isNew and items %}
<div class="br-section">
<h3>{{ __("blogroll.blogs.recentItems") }}</h3>
{% if items.length > 0 %}
<ul class="br-items-list">
{% for item in items %}
<li class="br-item">
<div class="br-item-title">
<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.title }}</a>
</div>
<div class="br-item-meta">
{{ item.published | date("PPpp") }}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="br-empty">{{ __("blogroll.blogs.noItems") }}</p>
{% endif %}
</div>
{% endif %}
{% if isNew %}
<script>
(function() {
@@ -311,9 +102,9 @@
function showResult(message, isError, isSuccess) {
discoverResult.style.display = 'block';
discoverResult.className = 'br-discover-result' +
(isError ? ' br-discover-result--error' : '') +
(isSuccess ? ' br-discover-result--success' : '');
discoverResult.className = 'blogroll-discover__result' +
(isError ? ' blogroll-discover__result--error' : '') +
(isSuccess ? ' blogroll-discover__result--success' : '');
discoverResult.textContent = '';
const span = document.createElement('span');
@@ -323,7 +114,7 @@
function showFeedUrl(message, url) {
discoverResult.style.display = 'block';
discoverResult.className = 'br-discover-result br-discover-result--success';
discoverResult.className = 'blogroll-discover__result blogroll-discover__result--success';
discoverResult.textContent = '';
const span = document.createElement('span');
@@ -379,19 +170,19 @@
showResult('{{ __("blogroll.blogs.form.discoverFoundMultiple") }}', false, true);
const feedList = document.createElement('ul');
feedList.className = 'br-discover-feeds';
feedList.className = 'blogroll-discover__feeds';
data.feeds.forEach(function(feed) {
const li = document.createElement('li');
li.className = 'br-discover-feed';
li.className = 'blogroll-discover__feed';
const typeSpan = document.createElement('span');
typeSpan.className = 'br-discover-feed-type';
typeSpan.className = 'blogroll-discover__feed-type';
typeSpan.textContent = feed.type;
li.appendChild(typeSpan);
const urlSpan = document.createElement('span');
urlSpan.className = 'br-discover-feed-url';
urlSpan.className = 'blogroll-discover__feed-url';
urlSpan.textContent = feed.url;
li.appendChild(urlSpan);

View File

@@ -1,187 +1,67 @@
{% extends "document.njk" %}
{% extends "layouts/blogroll.njk" %}
{% block content %}
<style>
.br-blogs {
display: flex;
flex-direction: column;
gap: var(--space-m, 1.5rem);
}
.br-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
align-items: center;
}
.br-filter-select {
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
min-width: 150px;
}
.br-blogs-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s, 0.75rem);
}
.br-blog-item {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1rem);
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
}
.br-blog-item--pinned {
border-left: 3px solid var(--color-primary, #0066cc);
}
.br-blog-item--hidden {
opacity: 0.6;
}
.br-blog-info {
flex: 1;
min-width: 200px;
}
.br-blog-title {
font: var(--font-subhead, bold 1rem/1.4 sans-serif);
margin-block-end: var(--space-2xs, 0.25rem);
}
.br-blog-title a {
color: inherit;
text-decoration: none;
}
.br-blog-title a:hover {
text-decoration: underline;
}
.br-blog-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
display: flex;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
align-items: center;
}
.br-blog-url {
color: var(--color-primary, #0066cc);
font: var(--font-caption, 0.75rem/1.4 monospace);
word-break: break-all;
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-blog-error {
color: var(--color-error, #dc3545);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-blog-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
}
.br-empty {
text-align: center;
padding: var(--space-xl, 2rem);
color: var(--color-on-offset, #666);
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.title") }}</a>
<h1 class="page-header__title">{{ __("blogroll.blogs.title") }}</h1>
</header>
{# Flash messages are now rendered by Indiekit's native notificationBanner via success/error template vars #}
<div class="br-blogs">
<div class="br-filters">
<form method="get" action="{{ baseUrl }}/blogs" style="display: flex; gap: var(--space-s, 0.75rem); flex-wrap: wrap; align-items: center;">
<select name="category" class="br-filter-select" onchange="this.form.submit()">
{% block blogroll %}
<div class="blogroll-filters">
<form method="get" action="{{ baseUrl }}/blogs" style="display: flex; gap: var(--space-s); flex-wrap: wrap; align-items: center;">
<select name="category" class="blogroll-filter-select" onchange="this.form.submit()">
<option value="">{{ __("blogroll.blogs.allCategories") }}</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if filterCategory == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
<select name="status" class="br-filter-select" onchange="this.form.submit()">
<select name="status" class="blogroll-filter-select" onchange="this.form.submit()">
<option value="">{{ __("blogroll.blogs.allStatuses") }}</option>
<option value="active" {% if filterStatus == 'active' %}selected{% endif %}>{{ __("blogroll.blogs.statusActive") }}</option>
<option value="error" {% if filterStatus == 'error' %}selected{% endif %}>{{ __("blogroll.blogs.statusError") }}</option>
<option value="pending" {% if filterStatus == 'pending' %}selected{% endif %}>{{ __("blogroll.blogs.statusPending") }}</option>
</select>
{% if filterCategory or filterStatus %}
<a href="{{ baseUrl }}/blogs" class="button button--small button--secondary">{{ __("blogroll.blogs.clearFilters") }}</a>
{{ button({ href: baseUrl + "/blogs", text: __("blogroll.blogs.clearFilters"), classes: "button--small button--secondary" }) }}
{% endif %}
</form>
</div>
<div class="button-group">
<a href="{{ baseUrl }}/blogs/new" class="button button--primary">
{{ __("blogroll.blogs.add") }}
</a>
<div class="blogroll-actions">
{{ button({ href: baseUrl + "/blogs/new", text: __("blogroll.blogs.add") }) }}
</div>
{% if blogs.length > 0 %}
<ul class="br-blogs-list">
<ul class="blogroll-list">
{% for blog in blogs %}
<li class="br-blog-item {% if blog.pinned %}br-blog-item--pinned{% endif %} {% if blog.hidden %}br-blog-item--hidden{% endif %}">
<div class="br-blog-info">
<h2 class="br-blog-title">
<li class="blogroll-list__item {% if blog.pinned %}blogroll-list__item--pinned{% endif %} {% if blog.hidden %}blogroll-list__item--hidden{% endif %}">
<div class="blogroll-item__info">
<h2 class="blogroll-item__title">
{% if blog.siteUrl %}
<a href="{{ blog.siteUrl }}" target="_blank" rel="noopener">{{ blog.title }}</a>
{% else %}
{{ blog.title }}
{% endif %}
</h2>
<p class="br-blog-meta">
<span class="badge {% if blog.status == 'active' %}badge--green{% elif blog.status == 'error' %}badge--red{% else %}badge--yellow{% endif %}">
{{ blog.status }}
</span>
<p class="blogroll-item__meta">
{{ badge({ color: "green" if blog.status == "active" else ("red" if blog.status == "error" else "yellow"), text: blog.status }) }}
{% if blog.category %}
<span>{{ blog.category }}</span>
{% endif %}
<span> {{ blog.itemCount or 0 }} items</span>
<span>· {{ blog.itemCount or 0 }} items</span>
{% if blog.pinned %}
<span> {{ __("blogroll.blogs.pinned") }}</span>
<span>· {{ __("blogroll.blogs.pinned") }}</span>
{% endif %}
{% if blog.hidden %}
<span> {{ __("blogroll.blogs.hidden") }}</span>
<span>· {{ __("blogroll.blogs.hidden") }}</span>
{% endif %}
</p>
<p class="br-blog-url">{{ blog.feedUrl }}</p>
<p class="blogroll-item__url">{{ blog.feedUrl }}</p>
{% if blog.lastError %}
<p class="br-blog-error">{{ blog.lastError }}</p>
<p class="blogroll-item__error">{{ blog.lastError }}</p>
{% endif %}
</div>
<div class="br-blog-actions">
<div class="blogroll-item__actions">
<form method="post" action="{{ baseUrl }}/blogs/{{ blog._id }}/refresh" style="display: inline;">
<button type="submit" class="button button--small button--secondary">
{{ icon("syndicate") }} {{ __("blogroll.refresh") }}
</button>
</form>
<a href="{{ baseUrl }}/blogs/{{ blog._id }}" class="button button--small button--secondary">
{{ icon("updatePost") }} {{ __("blogroll.edit") }}
</a>
{{ button({ href: baseUrl + "/blogs/" + blog._id, text: __("blogroll.edit"), classes: "button--small button--secondary" }) }}
<form method="post" action="{{ baseUrl }}/blogs/{{ blog._id }}/delete" style="display: inline;" onsubmit="return confirm('{{ __("blogroll.blogs.deleteConfirm") }}');">
<button type="submit" class="button button--small button--warning">
{{ icon("delete") }}
@@ -192,10 +72,9 @@
{% endfor %}
</ul>
{% else %}
<div class="br-empty">
<p>{{ __("blogroll.blogs.empty") }}</p>
<p><a href="{{ baseUrl }}/blogs/new" class="button button--primary">{{ __("blogroll.blogs.add") }}</a></p>
<div class="blogroll-empty">
{{ prose({ text: __("blogroll.blogs.empty") }) }}
{{ button({ href: baseUrl + "/blogs/new", text: __("blogroll.blogs.add") }) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,273 +1,116 @@
{% extends "document.njk" %}
{% extends "layouts/blogroll.njk" %}
{% block content %}
<style>
.br-dashboard {
display: flex;
flex-direction: column;
gap: var(--space-xl, 2rem);
}
.br-section {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1.5rem);
}
.br-section h2 {
font: var(--font-heading, bold 1.25rem/1.4 sans-serif);
margin-block-end: var(--space-s, 0.75rem);
padding-block-end: var(--space-xs, 0.5rem);
border-block-end: 1px solid var(--color-outline-variant, #ddd);
}
.br-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-s, 0.75rem);
}
.br-stat {
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-s, 0.75rem);
text-align: center;
}
.br-stat dt {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
margin-block-end: var(--space-2xs, 0.25rem);
}
.br-stat dd {
font: var(--font-subhead, bold 1.125rem/1.4 sans-serif);
margin: 0;
}
.br-stat dd.br-stat--error {
color: var(--color-error, #dc3545);
}
.br-quick-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
margin-block-start: var(--space-m, 1rem);
}
.br-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-list li {
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.25rem);
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
}
.br-list-name {
font: var(--font-body, 0.875rem/1.4 sans-serif);
font-weight: 600;
}
.br-list-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
}
.br-list-error {
color: var(--color-error, #dc3545);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
}
.br-api-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs, 0.5rem);
}
.br-api-list li {
background: var(--color-background, #fff);
border-radius: var(--border-radius-small, 0.25rem);
padding: var(--space-xs, 0.5rem) var(--space-s, 0.75rem);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-api-list code {
font-weight: 600;
color: var(--color-primary, #0066cc);
}
.br-empty {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
text-align: center;
padding: var(--space-m, 1rem);
}
</style>
<header class="page-header">
<h1 class="page-header__title">{{ __("blogroll.title") }}</h1>
<p class="page-header__description">{{ __("blogroll.description") }}</p>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<div class="br-dashboard">
<section class="br-section">
<h2>{{ __("blogroll.stats.title") }}</h2>
<dl class="br-stats-grid">
<div class="br-stat">
{% block blogroll %}
{% call section({ title: __("blogroll.stats.title") }) %}
<dl class="blogroll-stats">
<div class="blogroll-stat">
<dt>{{ __("blogroll.stats.sources") }}</dt>
<dd>{{ stats.sources }}</dd>
</div>
<div class="br-stat">
<div class="blogroll-stat">
<dt>{{ __("blogroll.stats.blogs") }}</dt>
<dd>{{ stats.blogs }}</dd>
</div>
<div class="br-stat">
<div class="blogroll-stat">
<dt>{{ __("blogroll.stats.items") }}</dt>
<dd>{{ stats.items }}</dd>
</div>
<div class="br-stat">
<div class="blogroll-stat">
<dt>{{ __("blogroll.stats.errors") }}</dt>
<dd class="{% if stats.errors > 0 %}br-stat--error{% endif %}">{{ stats.errors }}</dd>
<dd class="{% if stats.errors > 0 %}blogroll-stat--error{% endif %}">{{ stats.errors }}</dd>
</div>
<div class="br-stat">
<div class="blogroll-stat">
<dt>{{ __("blogroll.stats.lastSync") }}</dt>
<dd>{% if syncStatus.lastSync %}{{ syncStatus.lastSync | date("PPpp") }}{% else %}{{ __("blogroll.never") }}{% endif %}</dd>
</div>
</dl>
<div class="br-quick-links">
<a href="{{ baseUrl }}/sources" class="button button--secondary">
{{ __("blogroll.sources.manage") }}
</a>
<a href="{{ baseUrl }}/blogs" class="button button--secondary">
{{ __("blogroll.blogs.manage") }}
</a>
<div class="blogroll-actions">
{{ button({ href: baseUrl + "/sources", text: __("blogroll.sources.manage"), classes: "button--secondary" }) }}
{{ button({ href: baseUrl + "/blogs", text: __("blogroll.blogs.manage"), classes: "button--secondary" }) }}
</div>
</section>
{% endcall %}
<section class="br-section">
<h2>{{ __("blogroll.actions.title") }}</h2>
<div class="button-group">
{% call section({ title: __("blogroll.actions.title") }) %}
<div class="blogroll-actions">
<form method="post" action="{{ baseUrl }}/sync" style="display: inline;">
<button type="submit" class="button button--primary">
{{ __("blogroll.actions.syncNow") }}
</button>
{{ button({ type: "submit", text: __("blogroll.actions.syncNow") }) }}
</form>
<form method="post" action="{{ baseUrl }}/clear-resync" style="display: inline;" onsubmit="return confirm('{{ __("blogroll.actions.clearConfirm") }}');">
<button type="submit" class="button button--secondary">
{{ __("blogroll.actions.clearResync") }}
</button>
{{ button({ type: "submit", text: __("blogroll.actions.clearResync"), classes: "button--secondary" }) }}
</form>
</div>
</section>
{% endcall %}
{% if blogsWithErrors.length > 0 %}
<section class="br-section">
<h2>{{ __("blogroll.errors.title") }}</h2>
<ul class="br-list">
{% call section({ title: __("blogroll.errors.title") }) %}
<ul class="blogroll-list">
{% for blog in blogsWithErrors %}
<li>
<li class="blogroll-list__item blogroll-list__item--compact">
<div>
<span class="br-list-name">{{ blog.title }}</span>
<span class="br-list-error">{{ blog.lastError }}</span>
<span class="blogroll-item__title">{{ blog.title }}</span>
<span class="blogroll-item__error">{{ blog.lastError }}</span>
</div>
<a href="{{ baseUrl }}/blogs/{{ blog._id }}" class="button button--small button--secondary">
{{ __("blogroll.edit") }}
</a>
{{ button({ href: baseUrl + "/blogs/" + blog._id, text: __("blogroll.edit"), classes: "button--small button--secondary" }) }}
</li>
{% endfor %}
</ul>
{% if stats.errors > blogsWithErrors.length %}
<p class="br-empty">
<p class="blogroll-empty">
<a href="{{ baseUrl }}/blogs?status=error">{{ __("blogroll.errors.seeAll", { count: stats.errors }) }}</a>
</p>
{% endif %}
</section>
{% endcall %}
{% endif %}
<section class="br-section">
<h2>{{ __("blogroll.sources.recent") }}</h2>
{% call section({ title: __("blogroll.sources.recent") }) %}
{% if sources.length > 0 %}
<ul class="br-list">
<ul class="blogroll-list">
{% for source in sources %}
<li>
<li class="blogroll-list__item blogroll-list__item--compact">
<div>
<span class="br-list-name">{{ source.name }}</span>
<span class="br-list-meta">{{ source.type }} {% if source.lastSyncAt %}{{ source.lastSyncAt | date("PP") }}{% else %}{{ __("blogroll.never") }}{% endif %}</span>
<span class="blogroll-item__title">{{ source.name }}</span>
<span class="blogroll-item__meta">{{ source.type }} · {% if source.lastSyncAt %}{{ source.lastSyncAt | date("PP") }}{% else %}{{ __("blogroll.never") }}{% endif %}</span>
</div>
<span class="badge {% if source.enabled %}badge--green{% else %}badge--yellow{% endif %}">
{% if source.enabled %}{{ __("blogroll.enabled") }}{% else %}{{ __("blogroll.disabled") }}{% endif %}
</span>
{{ badge({ color: "green" if source.enabled else "yellow", text: __("blogroll.enabled") if source.enabled else __("blogroll.disabled") }) }}
</li>
{% endfor %}
</ul>
{% else %}
<p class="br-empty">{{ __("blogroll.sources.empty") }}</p>
{{ prose({ text: __("blogroll.sources.empty") }) }}
{% endif %}
<div class="br-quick-links">
<a href="{{ baseUrl }}/sources/new" class="button button--secondary button--small">
{{ __("blogroll.sources.add") }}
</a>
<div class="blogroll-actions">
{{ button({ href: baseUrl + "/sources/new", text: __("blogroll.sources.add"), classes: "button--secondary button--small" }) }}
</div>
</section>
{% endcall %}
<section class="br-section">
<h2>{{ __("blogroll.blogs.recent") }}</h2>
{% call section({ title: __("blogroll.blogs.recent") }) %}
{% if recentBlogs.length > 0 %}
<ul class="br-list">
<ul class="blogroll-list">
{% for blog in recentBlogs %}
<li>
<li class="blogroll-list__item blogroll-list__item--compact">
<div>
<span class="br-list-name">{{ blog.title }}</span>
<span class="br-list-meta">{{ blog.category or "Uncategorized" }} {{ blog.itemCount }} items</span>
<span class="blogroll-item__title">{{ blog.title }}</span>
<span class="blogroll-item__meta">{{ blog.category or "Uncategorized" }} · {{ blog.itemCount }} items</span>
</div>
<span class="badge {% if blog.status == 'active' %}badge--green{% elif blog.status == 'error' %}badge--red{% else %}badge--yellow{% endif %}">
{{ blog.status }}
</span>
{{ badge({ color: "green" if blog.status == "active" else ("red" if blog.status == "error" else "yellow"), text: blog.status }) }}
</li>
{% endfor %}
</ul>
{% else %}
<p class="br-empty">{{ __("blogroll.blogs.empty") }}</p>
{{ prose({ text: __("blogroll.blogs.empty") }) }}
{% endif %}
<div class="br-quick-links">
<a href="{{ baseUrl }}/blogs/new" class="button button--secondary button--small">
{{ __("blogroll.blogs.add") }}
</a>
<div class="blogroll-actions">
{{ button({ href: baseUrl + "/blogs/new", text: __("blogroll.blogs.add"), classes: "button--secondary button--small" }) }}
</div>
</section>
{% endcall %}
<section class="br-section">
<h2>{{ __("blogroll.api.title") }}</h2>
<ul class="br-api-list">
{% call section({ title: __("blogroll.api.title") }) %}
<ul class="blogroll-api-list">
<li><code>GET {{ baseUrl }}/api/blogs</code> - {{ __("blogroll.api.blogs") }}</li>
<li><code>GET {{ baseUrl }}/api/items</code> - {{ __("blogroll.api.items") }}</li>
<li><code>GET {{ baseUrl }}/api/categories</code> - {{ __("blogroll.api.categories") }}</li>
<li><code>GET {{ baseUrl }}/api/opml</code> - {{ __("blogroll.api.opml") }}</li>
<li><code>GET {{ baseUrl }}/api/status</code> - {{ __("blogroll.api.status") }}</li>
</ul>
</section>
</div>
{% endcall %}
{% endblock %}

View File

@@ -1,147 +1,75 @@
{% extends "document.njk" %}
{% extends "layouts/blogroll.njk" %}
{% block content %}
<style>
.br-form {
max-width: 600px;
}
{% block blogroll %}
<form method="post" action="{% if isNew %}{{ baseUrl }}/sources{% else %}{{ baseUrl }}/sources/{{ source._id }}{% endif %}" class="blogroll-form">
<div class="blogroll-field">
<label for="name">{{ __("blogroll.sources.form.name") }}</label>
<input type="text" id="name" name="name" value="{{ source.name if source else '' }}" required>
</div>
.br-field {
display: flex;
flex-direction: column;
gap: var(--space-2xs, 0.25rem);
margin-block-end: var(--space-m, 1rem);
}
<div class="blogroll-field">
<label for="type">{{ __("blogroll.sources.form.type") }}</label>
<select id="type" name="type" required onchange="toggleTypeFields()">
<option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL (auto-sync)</option>
<option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (one-time import)</option>
{% if microsubAvailable %}
<option value="microsub" {% if source.type == 'microsub' %}selected{% endif %}>Microsub Subscriptions</option>
{% endif %}
</select>
<span class="blogroll-field-hint">{{ __("blogroll.sources.form.typeHint") }}</span>
</div>
.br-field label {
font: var(--font-label, bold 0.875rem/1.4 sans-serif);
}
<div class="blogroll-field" id="urlField">
<label for="url">{{ __("blogroll.sources.form.url") }}</label>
<input type="url" id="url" name="url" value="{{ source.url if source else '' }}" placeholder="https://...">
<span class="blogroll-field-hint">{{ __("blogroll.sources.form.urlHint") }}</span>
</div>
.br-field-hint {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
<div class="blogroll-field" id="opmlContentField" style="display: none;">
<label for="opmlContent">{{ __("blogroll.sources.form.opmlContent") }}</label>
<textarea id="opmlContent" name="opmlContent" placeholder="<?xml version=&quot;1.0&quot;?>...">{{ source.opmlContent if source else '' }}</textarea>
<span class="blogroll-field-hint">{{ __("blogroll.sources.form.opmlContentHint") }}</span>
</div>
.br-field input,
.br-field select,
.br-field textarea {
appearance: none;
background-color: var(--color-background, #fff);
border: 1px solid var(--color-outline-variant, #ccc);
border-radius: var(--border-radius-small, 0.25rem);
font: var(--font-body, 0.875rem/1.4 sans-serif);
padding: calc(var(--space-s, 0.75rem) / 2) var(--space-s, 0.75rem);
width: 100%;
}
<div class="blogroll-field" id="microsubChannelField" style="display: none;">
<label for="channelFilter">{{ __("blogroll.sources.form.microsubChannel") | default("Microsub Channel") }}</label>
<select id="channelFilter" name="channelFilter">
<option value="">All channels</option>
{% for channel in microsubChannels %}
<option value="{{ channel.uid }}" {% if source.channelFilter == channel.uid %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<span class="blogroll-field-hint">{{ __("blogroll.sources.form.microsubChannelHint") | default("Sync feeds from a specific channel, or all channels") }}</span>
</div>
.br-field textarea {
min-height: 150px;
font-family: monospace;
}
<div class="blogroll-field" id="categoryPrefixField" style="display: none;">
<label for="categoryPrefix">{{ __("blogroll.sources.form.categoryPrefix") | default("Category Prefix") }}</label>
<input type="text" id="categoryPrefix" name="categoryPrefix" value="{{ source.categoryPrefix if source else '' }}" placeholder="e.g., Microsub: ">
<span class="blogroll-field-hint">{{ __("blogroll.sources.form.categoryPrefixHint") | default("Optional prefix for blog categories (e.g., 'Following: ')") }}</span>
</div>
.br-field input:focus,
.br-field select:focus,
.br-field textarea:focus {
border-color: var(--color-primary, #0066cc);
outline: 2px solid var(--color-primary, #0066cc);
outline-offset: 1px;
}
<div class="blogroll-field">
<label for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
<select id="syncInterval" name="syncInterval">
<option value="30" {% if source.syncInterval == 30 %}selected{% endif %}>30 minutes</option>
<option value="60" {% if not source or source.syncInterval == 60 %}selected{% endif %}>1 hour</option>
<option value="180" {% if source.syncInterval == 180 %}selected{% endif %}>3 hours</option>
<option value="360" {% if source.syncInterval == 360 %}selected{% endif %}>6 hours</option>
<option value="720" {% if source.syncInterval == 720 %}selected{% endif %}>12 hours</option>
<option value="1440" {% if source.syncInterval == 1440 %}selected{% endif %}>24 hours</option>
</select>
</div>
.br-field-inline {
flex-direction: row;
align-items: center;
gap: var(--space-s, 0.75rem);
}
<div class="blogroll-field blogroll-field--inline">
<input type="checkbox" id="enabled" name="enabled" {% if not source or source.enabled %}checked{% endif %}>
<label for="enabled">{{ __("blogroll.sources.form.enabled") }}</label>
</div>
.br-field-inline input[type="checkbox"] {
appearance: auto;
width: auto;
cursor: pointer;
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}/sources" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.sources.title") }}</a>
<h1 class="page-header__title">{{ title }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<form method="post" action="{% if isNew %}{{ baseUrl }}/sources{% else %}{{ baseUrl }}/sources/{{ source._id }}{% endif %}" class="br-form">
<div class="br-field">
<label for="name">{{ __("blogroll.sources.form.name") }}</label>
<input type="text" id="name" name="name" value="{{ source.name if source else '' }}" required>
</div>
<div class="br-field">
<label for="type">{{ __("blogroll.sources.form.type") }}</label>
<select id="type" name="type" required onchange="toggleTypeFields()">
<option value="opml_url" {% if source.type == 'opml_url' %}selected{% endif %}>OPML URL (auto-sync)</option>
<option value="opml_file" {% if source.type == 'opml_file' %}selected{% endif %}>OPML File (one-time import)</option>
{% if microsubAvailable %}
<option value="microsub" {% if source.type == 'microsub' %}selected{% endif %}>Microsub Subscriptions</option>
{% endif %}
</select>
<span class="br-field-hint">{{ __("blogroll.sources.form.typeHint") }}</span>
</div>
<div class="br-field" id="urlField">
<label for="url">{{ __("blogroll.sources.form.url") }}</label>
<input type="url" id="url" name="url" value="{{ source.url if source else '' }}" placeholder="https://...">
<span class="br-field-hint">{{ __("blogroll.sources.form.urlHint") }}</span>
</div>
<div class="br-field" id="opmlContentField" style="display: none;">
<label for="opmlContent">{{ __("blogroll.sources.form.opmlContent") }}</label>
<textarea id="opmlContent" name="opmlContent" placeholder="<?xml version=&quot;1.0&quot;?>...">{{ source.opmlContent if source else '' }}</textarea>
<span class="br-field-hint">{{ __("blogroll.sources.form.opmlContentHint") }}</span>
</div>
<div class="br-field" id="microsubChannelField" style="display: none;">
<label for="channelFilter">{{ __("blogroll.sources.form.microsubChannel") | default("Microsub Channel") }}</label>
<select id="channelFilter" name="channelFilter">
<option value="">All channels</option>
{% for channel in microsubChannels %}
<option value="{{ channel.uid }}" {% if source.channelFilter == channel.uid %}selected{% endif %}>{{ channel.name }}</option>
{% endfor %}
</select>
<span class="br-field-hint">{{ __("blogroll.sources.form.microsubChannelHint") | default("Sync feeds from a specific channel, or all channels") }}</span>
</div>
<div class="br-field" id="categoryPrefixField" style="display: none;">
<label for="categoryPrefix">{{ __("blogroll.sources.form.categoryPrefix") | default("Category Prefix") }}</label>
<input type="text" id="categoryPrefix" name="categoryPrefix" value="{{ source.categoryPrefix if source else '' }}" placeholder="e.g., Microsub: ">
<span class="br-field-hint">{{ __("blogroll.sources.form.categoryPrefixHint") | default("Optional prefix for blog categories (e.g., 'Following: ')") }}</span>
</div>
<div class="br-field">
<label for="syncInterval">{{ __("blogroll.sources.form.syncInterval") }}</label>
<select id="syncInterval" name="syncInterval">
<option value="30" {% if source.syncInterval == 30 %}selected{% endif %}>30 minutes</option>
<option value="60" {% if not source or source.syncInterval == 60 %}selected{% endif %}>1 hour</option>
<option value="180" {% if source.syncInterval == 180 %}selected{% endif %}>3 hours</option>
<option value="360" {% if source.syncInterval == 360 %}selected{% endif %}>6 hours</option>
<option value="720" {% if source.syncInterval == 720 %}selected{% endif %}>12 hours</option>
<option value="1440" {% if source.syncInterval == 1440 %}selected{% endif %}>24 hours</option>
</select>
</div>
<div class="br-field br-field-inline">
<input type="checkbox" id="enabled" name="enabled" {% if not source or source.enabled %}checked{% endif %}>
<label for="enabled">{{ __("blogroll.sources.form.enabled") }}</label>
</div>
<div class="button-group">
<button type="submit" class="button button--primary">
{% if isNew %}{{ __("blogroll.sources.create") }}{% else %}{{ __("blogroll.sources.save") }}{% endif %}
</button>
<a href="{{ baseUrl }}/sources" class="button button--secondary">{{ __("blogroll.cancel") }}</a>
</div>
</form>
<div class="blogroll-actions">
{{ button({ type: "submit", text: __("blogroll.sources.create") if isNew else __("blogroll.sources.save") }) }}
{{ button({ href: baseUrl + "/sources", text: __("blogroll.cancel"), classes: "button--secondary" }) }}
</div>
</form>
<script>
function toggleTypeFields() {

View File

@@ -1,125 +1,39 @@
{% extends "document.njk" %}
{% extends "layouts/blogroll.njk" %}
{% block content %}
<style>
.br-sources {
display: flex;
flex-direction: column;
gap: var(--space-m, 1.5rem);
}
.br-sources-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s, 0.75rem);
}
.br-source-item {
background: var(--color-offset, #f5f5f5);
border-radius: var(--border-radius-small, 0.5rem);
padding: var(--space-m, 1rem);
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-s, 0.75rem);
}
.br-source-info {
flex: 1;
min-width: 200px;
}
.br-source-name {
font: var(--font-subhead, bold 1rem/1.4 sans-serif);
margin-block-end: var(--space-2xs, 0.25rem);
}
.br-source-meta {
color: var(--color-on-offset, #666);
font: var(--font-caption, 0.875rem/1.4 sans-serif);
}
.br-source-url {
color: var(--color-primary, #0066cc);
font: var(--font-caption, 0.75rem/1.4 monospace);
word-break: break-all;
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-source-error {
color: var(--color-error, #dc3545);
font: var(--font-caption, 0.75rem/1.4 sans-serif);
margin-block-start: var(--space-2xs, 0.25rem);
}
.br-source-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs, 0.5rem);
}
.br-empty {
text-align: center;
padding: var(--space-xl, 2rem);
color: var(--color-on-offset, #666);
}
</style>
<header class="page-header">
<a href="{{ baseUrl }}" class="page-header__back">{{ icon("previous") }} {{ __("blogroll.title") }}</a>
<h1 class="page-header__title">{{ __("blogroll.sources.title") }}</h1>
</header>
{% for message in request.session.messages %}
<div class="notice notice--{{ message.type }}">
<p>{{ message.content }}</p>
</div>
{% endfor %}
<div class="br-sources">
<div class="button-group">
<a href="{{ baseUrl }}/sources/new" class="button button--primary">
{{ __("blogroll.sources.add") }}
</a>
{% block blogroll %}
<div class="blogroll-actions">
{{ button({ href: baseUrl + "/sources/new", text: __("blogroll.sources.add") }) }}
</div>
{% if sources.length > 0 %}
<ul class="br-sources-list">
<ul class="blogroll-list">
{% for source in sources %}
<li class="br-source-item">
<div class="br-source-info">
<h2 class="br-source-name">{{ source.name }}</h2>
<p class="br-source-meta">
<span class="badge {% if source.enabled %}badge--green{% else %}badge--yellow{% endif %}">
{% if source.enabled %}{{ __("blogroll.enabled") }}{% else %}{{ __("blogroll.disabled") }}{% endif %}
</span>
<li class="blogroll-list__item">
<div class="blogroll-item__info">
<h2 class="blogroll-item__title">{{ source.name }}</h2>
<p class="blogroll-item__meta">
{{ badge({ color: "green" if source.enabled else "yellow", text: __("blogroll.enabled") if source.enabled else __("blogroll.disabled") }) }}
<span>{{ source.type }}</span>
<span> {{ __("blogroll.sources.interval", { minutes: source.syncInterval }) }}</span>
<span>· {{ __("blogroll.sources.interval", { minutes: source.syncInterval }) }}</span>
</p>
{% if source.url %}
<p class="br-source-url">{{ source.url }}</p>
<p class="blogroll-item__url">{{ source.url }}</p>
{% endif %}
{% if source.lastSyncError %}
<p class="br-source-error">{{ source.lastSyncError }}</p>
<p class="blogroll-item__error">{{ source.lastSyncError }}</p>
{% endif %}
<p class="br-source-meta">
<p class="blogroll-item__meta">
{{ __("blogroll.sources.lastSync") }}:
{% if source.lastSyncAt %}{{ source.lastSyncAt | date("PPpp") }}{% else %}{{ __("blogroll.never") }}{% endif %}
</p>
</div>
<div class="br-source-actions">
<div class="blogroll-item__actions">
<form method="post" action="{{ baseUrl }}/sources/{{ source._id }}/sync" style="display: inline;">
<button type="submit" class="button button--small button--secondary">
{{ icon("syndicate") }} {{ __("blogroll.sync") }}
</button>
</form>
<a href="{{ baseUrl }}/sources/{{ source._id }}" class="button button--small button--secondary">
{{ icon("updatePost") }} {{ __("blogroll.edit") }}
</a>
{{ button({ href: baseUrl + "/sources/" + source._id, text: __("blogroll.edit"), classes: "button--small button--secondary" }) }}
<form method="post" action="{{ baseUrl }}/sources/{{ source._id }}/delete" style="display: inline;" onsubmit="return confirm('{{ __("blogroll.sources.deleteConfirm") }}');">
<button type="submit" class="button button--small button--warning">
{{ icon("delete") }}
@@ -130,10 +44,9 @@
{% endfor %}
</ul>
{% else %}
<div class="br-empty">
<p>{{ __("blogroll.sources.empty") }}</p>
<p><a href="{{ baseUrl }}/sources/new" class="button button--primary">{{ __("blogroll.sources.add") }}</a></p>
<div class="blogroll-empty">
{{ prose({ text: __("blogroll.sources.empty") }) }}
{{ button({ href: baseUrl + "/sources/new", text: __("blogroll.sources.add") }) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,6 @@
{% extends "document.njk" %}
{% block content %}
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-blogroll/styles.css">
{% block blogroll %}{% endblock %}
{% endblock %}