mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 08:44:56 +02:00
feat: blog filter nav, interactions pagination, note unfurl, pagefind improvements
- Replace broken client-side type filter on /blog/ with navigation pill links to dedicated collection pages (with post counts) - Replace Load More with proper prev/next/page-number pagination on Interactions inbound tab (20 per page, filter resets page) - Add auto-unfurl transform for standalone external links in notes - Exclude Digest and Categories pages from Pagefind search index - Add Pagefind search filters for post type, year, and category - Add Pagefind filter metadata to page.njk layout Confab-Link: http://localhost:8080/sessions/956f4251-b4a9-4bc9-b214-53402ad1fe63
This commit is contained in:
@@ -133,4 +133,18 @@ withSidebar: true
|
||||
{# Hidden metadata for microformats #}
|
||||
<a class="u-url hidden" href="{{ page.url }}"></a>
|
||||
<data class="p-author h-card hidden" value="{{ site.author.name }}"></data>
|
||||
|
||||
{# Pagefind filter metadata #}
|
||||
<div hidden>
|
||||
<span data-pagefind-filter="type">Page</span>
|
||||
{% if category %}
|
||||
{% if category is string %}
|
||||
<span data-pagefind-filter="category">{{ category }}</span>
|
||||
{% else %}
|
||||
{% for cat in category %}
|
||||
<span data-pagefind-filter="category">{{ cat }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
25
blog.njk
25
blog.njk
@@ -22,20 +22,13 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
</p>
|
||||
|
||||
{% if paginatedPosts.length > 0 %}
|
||||
<filter-container oninit leave-url-alone>
|
||||
<div class="flex flex-wrap gap-3 mb-6">
|
||||
<select data-filter-key="type" class="px-3 py-1.5 text-sm bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg">
|
||||
<option value="">All Types</option>
|
||||
<option value="article">Articles</option>
|
||||
<option value="note">Notes</option>
|
||||
<option value="photo">Photos</option>
|
||||
<option value="bookmark">Bookmarks</option>
|
||||
<option value="like">Likes</option>
|
||||
<option value="reply">Replies</option>
|
||||
<option value="repost">Reposts</option>
|
||||
</select>
|
||||
<span data-filter-results class="text-sm text-surface-500 dark:text-surface-400 self-center"></span>
|
||||
</div>
|
||||
<nav class="flex flex-wrap gap-2 mb-6" aria-label="Filter by post type">
|
||||
<a href="/blog/" class="px-3 py-1.5 text-sm font-medium rounded-full bg-accent-600 text-white dark:bg-accent-500">All Posts <span class="opacity-75">({{ collections.posts.length }})</span></a>
|
||||
{% for pt in enabledPostTypes %}
|
||||
{% set collName = pt.label | lower %}
|
||||
<a href="{{ pt.path }}" class="px-3 py-1.5 text-sm font-medium rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 border border-surface-200 dark:border-surface-700 transition-colors">{{ pt.label }}{% if collections[collName] %} <span class="text-surface-400 dark:text-surface-500">({{ collections[collName].length }})</span>{% endif %}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<ul class="post-list">
|
||||
{% for post in paginatedPosts %}
|
||||
{# Detect post type from frontmatter properties #}
|
||||
@@ -44,7 +37,6 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
|
||||
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
|
||||
{% set hasPhotos = post.data.photo and post.data.photo.length %}
|
||||
{% set _postType %}{% if likedUrl %}like{% elif bookmarkedUrl %}bookmark{% elif repostedUrl %}repost{% elif replyToUrl %}reply{% elif hasPhotos %}photo{% elif post.data.title %}article{% else %}note{% endif %}{% endset %}
|
||||
{% set borderClass = "" %}
|
||||
{% if likedUrl %}
|
||||
{% set borderClass = "border-l-[3px] border-l-red-400 dark:border-l-red-500" %}
|
||||
@@ -59,7 +51,7 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% else %}
|
||||
{% set borderClass = "border-l-[3px] border-l-surface-300 dark:border-l-surface-600" %}
|
||||
{% endif %}
|
||||
<li class="h-entry post-card {{ borderClass }}" data-filter-type="{{ _postType | trim }}">
|
||||
<li class="h-entry post-card {{ borderClass }}">
|
||||
|
||||
{% if likedUrl %}
|
||||
{# ── Like card ── #}
|
||||
@@ -341,7 +333,6 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</filter-container>
|
||||
|
||||
{# Pagination controls #}
|
||||
{% if pagination.pages.length > 1 %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
layout: layouts/base.njk
|
||||
title: Categories
|
||||
withSidebar: true
|
||||
pagefindIgnore: true
|
||||
permalink: categories/
|
||||
eleventyImport:
|
||||
collections:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
layout: layouts/base.njk
|
||||
withSidebar: true
|
||||
pagefindIgnore: true
|
||||
pagination:
|
||||
data: collections.categories
|
||||
size: 1
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
layout: layouts/base.njk
|
||||
title: Weekly Digest
|
||||
withSidebar: true
|
||||
pagefindIgnore: true
|
||||
eleventyExcludeFromCollections: true
|
||||
eleventyImport:
|
||||
collections:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
layout: layouts/base.njk
|
||||
withSidebar: true
|
||||
pagefindIgnore: true
|
||||
eleventyExcludeFromCollections: true
|
||||
eleventyImport:
|
||||
collections:
|
||||
|
||||
@@ -386,6 +386,52 @@ export default function (eleventyConfig) {
|
||||
return content;
|
||||
});
|
||||
|
||||
// Auto-unfurl standalone external links in note content
|
||||
// Finds <a> tags that are the primary content of a <p> tag and injects OG preview cards
|
||||
eleventyConfig.addTransform("auto-unfurl-notes", async function (content, outputPath) {
|
||||
if (!outputPath || !outputPath.endsWith(".html")) return content;
|
||||
// Only process note pages (individual + listing)
|
||||
if (!outputPath.includes("/notes/")) return content;
|
||||
|
||||
// Match <p> tags whose content is short text + a single external <a> as the last element
|
||||
// Pattern: <p>optional short text <a href="https://external.example">...</a></p>
|
||||
const linkParagraphRe = /<p>([^<]{0,80})?<a\s+href="(https?:\/\/[^"]+)"[^>]*>[^<]*<\/a>\s*<\/p>/g;
|
||||
const siteHost = new URL(siteUrl).hostname;
|
||||
const matches = [];
|
||||
|
||||
let match;
|
||||
while ((match = linkParagraphRe.exec(content)) !== null) {
|
||||
const url = match[2];
|
||||
try {
|
||||
const linkHost = new URL(url).hostname;
|
||||
// Skip same-domain links and common non-content URLs
|
||||
if (linkHost === siteHost || linkHost.endsWith("." + siteHost)) continue;
|
||||
matches.push({ fullMatch: match[0], url, index: match.index });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return content;
|
||||
|
||||
// Unfurl all matched URLs in parallel (uses cache, throttles network)
|
||||
const cards = await Promise.all(matches.map(m => prefetchUrl(m.url)));
|
||||
|
||||
// Replace in reverse order to preserve indices
|
||||
let result = content;
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const m = matches[i];
|
||||
const card = cards[i];
|
||||
// Skip if unfurl returned just a fallback link (no OG data)
|
||||
if (!card || !card.includes("unfurl-card")) continue;
|
||||
// Insert the unfurl card after the paragraph
|
||||
const insertPos = m.index + m.fullMatch.length;
|
||||
result = result.slice(0, insertPos) + "\n" + card + "\n" + result.slice(insertPos);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// HTML minification — only during initial build, skip during watch rebuilds
|
||||
eleventyConfig.addTransform("htmlmin", async function (content, outputPath) {
|
||||
if (outputPath && outputPath.endsWith(".html") && process.env.ELEVENTY_RUN_MODE === "build") {
|
||||
|
||||
193
interactions.njk
193
interactions.njk
@@ -283,14 +283,43 @@ permalink: /interactions/
|
||||
<p>No webmentions found for this filter.</p>
|
||||
</div>
|
||||
|
||||
{# Pagination / Load more #}
|
||||
<div x-show="hasMore" class="text-center pt-4">
|
||||
<button
|
||||
@click="loadMore()"
|
||||
:disabled="loadingMore"
|
||||
class="px-4 py-2 bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700 rounded-lg text-sm transition-colors disabled:opacity-50">
|
||||
<span x-text="loadingMore ? 'Loading...' : 'Load More'"></span>
|
||||
</button>
|
||||
{# Pagination controls #}
|
||||
<div x-show="totalPages > 1" class="pt-6">
|
||||
<nav class="pagination" aria-label="Webmentions pagination">
|
||||
<div class="pagination-info">
|
||||
Page <span x-text="currentPage"></span> of <span x-text="totalPages"></span>
|
||||
<span class="text-surface-400 dark:text-surface-500 ml-1">(<span x-text="filteredWebmentions.length"></span> total)</span>
|
||||
</div>
|
||||
<div class="pagination-links">
|
||||
<button
|
||||
@click="goToPage(currentPage - 1)"
|
||||
:disabled="currentPage <= 1"
|
||||
class="pagination-link"
|
||||
:class="currentPage <= 1 ? 'disabled' : ''">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<template x-for="p in pageNumbers" :key="p">
|
||||
<button
|
||||
@click="typeof p === 'number' && goToPage(p)"
|
||||
:disabled="p === '…'"
|
||||
:class="p === currentPage ? 'bg-accent-600 text-white dark:bg-accent-500' : p === '…' ? 'cursor-default opacity-50' : 'bg-surface-100 dark:bg-surface-800 hover:bg-surface-200 dark:hover:bg-surface-700'"
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors"
|
||||
x-text="p">
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
@click="goToPage(currentPage + 1)"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="pagination-link"
|
||||
:class="currentPage >= totalPages ? 'disabled' : ''">
|
||||
Next
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -316,14 +345,13 @@ function interactionsApp() {
|
||||
return {
|
||||
activeTab: 'inbound',
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: null,
|
||||
notConfigured: false,
|
||||
webmentions: [],
|
||||
filterType: 'all',
|
||||
page: 0,
|
||||
perPage: 50,
|
||||
hasMore: true,
|
||||
currentPage: 1,
|
||||
displayPerPage: 20,
|
||||
fetchPerPage: 200,
|
||||
refreshInterval: null,
|
||||
|
||||
get likes() {
|
||||
@@ -351,14 +379,41 @@ function interactionsApp() {
|
||||
return this.webmentions.filter(wm => wm['wm-property'] === this.filterType);
|
||||
},
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.filteredWebmentions.length / this.displayPerPage));
|
||||
},
|
||||
|
||||
get paginatedWebmentions() {
|
||||
// Client-side pagination of filtered results
|
||||
return this.filteredWebmentions;
|
||||
const start = (this.currentPage - 1) * this.displayPerPage;
|
||||
return this.filteredWebmentions.slice(start, start + this.displayPerPage);
|
||||
},
|
||||
|
||||
get pageNumbers() {
|
||||
const total = this.totalPages;
|
||||
const current = this.currentPage;
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const pages = [];
|
||||
pages.push(1);
|
||||
if (current > 3) pages.push('…');
|
||||
for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (current < total - 2) pages.push('…');
|
||||
pages.push(total);
|
||||
return pages;
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page < 1 || page > this.totalPages) return;
|
||||
this.currentPage = page;
|
||||
// Scroll to top of inbound tab
|
||||
this.$el.closest('[x-show]')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Reset page when filter changes
|
||||
this.$watch('filterType', () => { this.currentPage = 1; });
|
||||
await this.fetchWebmentions();
|
||||
// Auto-refresh every 5 minutes (skip if not configured)
|
||||
if (!this.notConfigured) {
|
||||
this.refreshInterval = setInterval(() => this.fetchWebmentions(true), 5 * 60 * 1000);
|
||||
}
|
||||
@@ -367,46 +422,60 @@ function interactionsApp() {
|
||||
async fetchWebmentions(silent = false) {
|
||||
if (!silent) {
|
||||
this.loading = true;
|
||||
this.page = 0;
|
||||
this.webmentions = [];
|
||||
this.hasMore = true;
|
||||
}
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
// Fetch from both webmention-io and conversations APIs in parallel
|
||||
const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
||||
const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
||||
// Fetch all available webmentions by paging through the APIs
|
||||
let allWm = [];
|
||||
let allConv = [];
|
||||
let wmPage = 0;
|
||||
let wmDone = false;
|
||||
let wmConfigured = true;
|
||||
|
||||
const [wmResult, convResult] = await Promise.allSettled([
|
||||
fetch(wmUrl).then(r => {
|
||||
if (r.status === 404) return { children: [], notConfigured: true };
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch(convUrl).then(r => {
|
||||
if (!r.ok) return { children: [] };
|
||||
return r.json();
|
||||
}).catch(() => ({ children: [] })),
|
||||
]);
|
||||
// Fetch all pages from both APIs
|
||||
while (!wmDone) {
|
||||
const [wmResult, convResult] = await Promise.allSettled([
|
||||
fetch(`/webmentions/api/mentions?per-page=${this.fetchPerPage}&page=${wmPage}`).then(r => {
|
||||
if (r.status === 404) return { children: [], notConfigured: true };
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch(`/conversations/api/mentions?per-page=${this.fetchPerPage}&page=${wmPage}`).then(r => {
|
||||
if (!r.ok) return { children: [] };
|
||||
return r.json();
|
||||
}).catch(() => ({ children: [] })),
|
||||
]);
|
||||
|
||||
const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] };
|
||||
const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] };
|
||||
const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] };
|
||||
const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] };
|
||||
|
||||
// Check if webmention-io is configured
|
||||
if (wmData.notConfigured && (!convData.children || !convData.children.length)) {
|
||||
if (wmData.notConfigured) wmConfigured = false;
|
||||
|
||||
allWm = allWm.concat(wmData.children || []);
|
||||
allConv = allConv.concat(convData.children || []);
|
||||
|
||||
// Stop if both APIs returned fewer than a full page
|
||||
const wmCount = (wmData.children || []).length;
|
||||
const convCount = (convData.children || []).length;
|
||||
if (wmCount < this.fetchPerPage && convCount < this.fetchPerPage) {
|
||||
wmDone = true;
|
||||
} else {
|
||||
wmPage++;
|
||||
// Safety cap to prevent infinite loops
|
||||
if (wmPage > 20) wmDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wmConfigured && allConv.length === 0) {
|
||||
this.notConfigured = true;
|
||||
return;
|
||||
}
|
||||
this.notConfigured = false;
|
||||
|
||||
// Merge and deduplicate - conversations items (with platform field) take priority
|
||||
const merged = this.mergeAndDeduplicate(
|
||||
wmData.children || [],
|
||||
convData.children || []
|
||||
);
|
||||
const merged = this.mergeAndDeduplicate(allWm, allConv);
|
||||
|
||||
// Sort by date, newest first
|
||||
merged.sort((a, b) => {
|
||||
const dateA = new Date(a.published || a['wm-received'] || 0);
|
||||
const dateB = new Date(b.published || b['wm-received'] || 0);
|
||||
@@ -414,7 +483,7 @@ function interactionsApp() {
|
||||
});
|
||||
|
||||
this.webmentions = merged;
|
||||
this.hasMore = (wmData.children || []).length === this.perPage;
|
||||
if (!silent) this.currentPage = 1;
|
||||
} catch (err) {
|
||||
this.error = `Failed to load webmentions: ${err.message}`;
|
||||
console.error('[Interactions]', err);
|
||||
@@ -426,22 +495,18 @@ function interactionsApp() {
|
||||
detectPlatform(item) {
|
||||
const source = item['wm-source'] || '';
|
||||
const authorUrl = item.author?.url || '';
|
||||
// Bridgy source URLs: brid.gy/{action}/{platform}/...
|
||||
if (source.includes('brid.gy/') && source.includes('/mastodon/')) return 'mastodon';
|
||||
if (source.includes('brid.gy/') && source.includes('/bluesky/')) return 'bluesky';
|
||||
// Author URL heuristics
|
||||
if (authorUrl.includes('bsky.app')) return 'bluesky';
|
||||
if (authorUrl.includes('mstdn') || authorUrl.includes('mastodon') || authorUrl.includes('social.')) return 'mastodon';
|
||||
return null;
|
||||
},
|
||||
|
||||
mergeAndDeduplicate(wmItems, convItems) {
|
||||
// Build a Set of source URLs from conversations for dedup
|
||||
const convUrls = new Set(convItems.map(c => c.url).filter(Boolean));
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
|
||||
// Add all conversations items first (they have richer metadata)
|
||||
for (const item of convItems) {
|
||||
const key = item['wm-id'] || item.url;
|
||||
if (key && !seen.has(key)) {
|
||||
@@ -450,16 +515,11 @@ function interactionsApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add webmention-io items that aren't duplicated by conversations
|
||||
for (const item of wmItems) {
|
||||
const wmKey = item['wm-id'];
|
||||
if (seen.has(wmKey)) continue;
|
||||
|
||||
// Also check if this webmention's source URL matches a conversations item
|
||||
// (same interaction from Bridgy webmention AND direct poll)
|
||||
if (item.url && convUrls.has(item.url)) continue;
|
||||
|
||||
// Infer platform from Bridgy source URL or author URL
|
||||
if (!item.platform) {
|
||||
const detected = this.detectPlatform(item);
|
||||
if (detected) item.platform = detected;
|
||||
@@ -472,36 +532,6 @@ function interactionsApp() {
|
||||
return result;
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
this.loadingMore = true;
|
||||
this.page++;
|
||||
|
||||
try {
|
||||
const wmUrl = `/webmentions/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
||||
const convUrl = `/conversations/api/mentions?per-page=${this.perPage}&page=${this.page}`;
|
||||
|
||||
const [wmResult, convResult] = await Promise.allSettled([
|
||||
fetch(wmUrl).then(r => r.ok ? r.json() : { children: [] }),
|
||||
fetch(convUrl).then(r => r.ok ? r.json() : { children: [] }).catch(() => ({ children: [] })),
|
||||
]);
|
||||
|
||||
const wmData = wmResult.status === 'fulfilled' ? wmResult.value : { children: [] };
|
||||
const convData = convResult.status === 'fulfilled' ? convResult.value : { children: [] };
|
||||
|
||||
const merged = this.mergeAndDeduplicate(
|
||||
wmData.children || [],
|
||||
convData.children || []
|
||||
);
|
||||
|
||||
this.webmentions = [...this.webmentions, ...merged];
|
||||
this.hasMore = (wmData.children || []).length === this.perPage;
|
||||
} catch (err) {
|
||||
this.error = `Failed to load more: ${err.message}`;
|
||||
} finally {
|
||||
this.loadingMore = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
@@ -518,7 +548,6 @@ function interactionsApp() {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const u = new URL(url);
|
||||
// Return just the pathname, trimmed
|
||||
let path = u.pathname;
|
||||
if (path.length > 50) {
|
||||
path = path.slice(0, 47) + '...';
|
||||
|
||||
@@ -19,7 +19,7 @@ pagefindIgnore: true
|
||||
</noscript>
|
||||
|
||||
<script>
|
||||
initPagefind("#search", { showSubResults: true });
|
||||
initPagefind("#search", { showSubResults: true, showEmptyFilters: false });
|
||||
|
||||
// Support ?q= query parameter and auto-focus
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
Reference in New Issue
Block a user