mirror of
https://github.com/svemagie/indiekit-endpoint-blogroll.git
synced 2026-04-02 15:34:59 +02:00
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:
342
assets/styles.css
Normal file
342
assets/styles.css
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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="1.0"?>...">{{ 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="1.0"?>...">{{ 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() {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
6
views/layouts/blogroll.njk
Normal file
6
views/layouts/blogroll.njk
Normal 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 %}
|
||||
Reference in New Issue
Block a user